Skip to content

Commit 9c33233

Browse files
phernandezclaude
andcommitted
refactor(cli): convert CLI commands to use context manager pattern
Convert all CLI commands from module-level client import to the new get_client() context manager pattern, removing manual auth header management. Changes to command_utils.py: - Import get_client() instead of client from async_client module - Remove get_authenticated_headers() import - Wrap run_sync() in async with get_client() context manager - Wrap get_project_info() in async with get_client() context manager - Remove all manual auth header handling Changes to status.py: - Import get_client() instead of client from async_client module - Remove get_authenticated_headers() import - Wrap run_status() in async with get_client() context manager - Remove all manual auth header handling Changes to project.py: - Import get_client() instead of client from async_client module - Remove get_authenticated_headers() import - Update all project commands to use context manager pattern: - list_projects() - add_project_cloud() (cloud mode) - add_project() (local mode) - remove_project() - set_default_project() - synchronize_projects() - move_project() - Remove all manual auth header handling Pattern Applied: Each CLI command now uses a nested async function with context manager: ```python async def _operation(): async with get_client() as client: response = await call_*(client, "/path") return result result = asyncio.run(_operation()) ``` Benefits: - Auth now happens at client creation, not per-request - Cloud app can inject custom transport via factory - Cleaner code without manual header management - Proper client lifecycle management - Tests can mock client behavior Testing: - Typecheck passed (0 errors, 0 warnings) - All CLI commands properly scoped ✅ Phase 0.4 Complete: All CLI commands converted to context manager pattern Related to SPEC-16 MCP Cloud Service Consolidation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1f48bef commit 9c33233

4 files changed

Lines changed: 74 additions & 95 deletions

File tree

specs/SPEC-16 MCP Cloud Service Consolidation.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,9 +469,9 @@ Convert from `from async_client import client` to `async with get_client() as cl
469469
#### 0.4 Update CLI Commands (~3 files)
470470
Remove manual auth header passing, use context manager:
471471

472-
- [ ] `cli/commands/project.py` - remove get_authenticated_headers() calls
473-
- [ ] `cli/commands/status.py` - use context manager
474-
- [ ] `cli/commands/command_utils.py` - use context manager
472+
- [x] `cli/commands/project.py` - removed get_authenticated_headers() calls, use context manager
473+
- [x] `cli/commands/status.py` - use context manager
474+
- [x] `cli/commands/command_utils.py` - use context manager
475475

476476
#### 0.5 Update Config
477477
- [x] Remove `api_url` field from `BasicMemoryConfig` in config.py

src/basic_memory/cli/commands/command_utils.py

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77

88
from rich.console import Console
99

10-
from basic_memory.cli.commands.cloud import get_authenticated_headers
11-
from basic_memory.mcp.async_client import client
10+
from basic_memory.mcp.async_client import get_client
1211

1312
from basic_memory.mcp.tools.utils import call_post, call_get
1413
from basic_memory.mcp.project_context import get_active_project
@@ -21,40 +20,24 @@ async def run_sync(project: Optional[str] = None):
2120
"""Run sync operation via API endpoint."""
2221

2322
try:
24-
from basic_memory.config import ConfigManager
25-
26-
config = ConfigManager().config
27-
auth_headers = {}
28-
if config.cloud_mode_enabled:
29-
auth_headers = await get_authenticated_headers()
30-
31-
project_item = await get_active_project(client, project, None, headers=auth_headers)
32-
response = await call_post(
33-
client, f"{project_item.project_url}/project/sync", headers=auth_headers
34-
)
35-
data = response.json()
36-
console.print(f"[green]✓ {data['message']}[/green]")
23+
async with get_client() as client:
24+
project_item = await get_active_project(client, project, None)
25+
response = await call_post(client, f"{project_item.project_url}/project/sync")
26+
data = response.json()
27+
console.print(f"[green]✓ {data['message']}[/green]")
3728
except (ToolError, ValueError) as e:
3829
console.print(f"[red]✗ Sync failed: {e}[/red]")
3930
raise typer.Exit(1)
4031

4132

4233
async def get_project_info(project: str):
43-
"""Run sync operation via API endpoint."""
34+
"""Get project information via API endpoint."""
4435

4536
try:
46-
from basic_memory.config import ConfigManager
47-
48-
config = ConfigManager().config
49-
auth_headers = {}
50-
if config.cloud_mode_enabled:
51-
auth_headers = await get_authenticated_headers()
52-
53-
project_item = await get_active_project(client, project, None, headers=auth_headers)
54-
response = await call_get(
55-
client, f"{project_item.project_url}/project/info", headers=auth_headers
56-
)
57-
return ProjectInfoResponse.model_validate(response.json())
37+
async with get_client() as client:
38+
project_item = await get_active_project(client, project, None)
39+
response = await call_get(client, f"{project_item.project_url}/project/info")
40+
return ProjectInfoResponse.model_validate(response.json())
5841
except (ToolError, ValueError) as e:
5942
console.print(f"[red]✗ Sync failed: {e}[/red]")
6043
raise typer.Exit(1)

src/basic_memory/cli/commands/project.py

Lines changed: 54 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@
99
from rich.table import Table
1010

1111
from basic_memory.cli.app import app
12-
from basic_memory.cli.commands.cloud import get_authenticated_headers
1312
from basic_memory.cli.commands.command_utils import get_project_info
1413
from basic_memory.config import ConfigManager
1514
import json
1615
from datetime import datetime
1716

1817
from rich.panel import Panel
19-
from basic_memory.mcp.async_client import client
18+
from basic_memory.mcp.async_client import get_client
2019
from basic_memory.mcp.tools.utils import call_get
2120
from basic_memory.schemas.project_info import ProjectList
2221
from basic_memory.mcp.tools.utils import call_post
@@ -46,14 +45,14 @@ def format_path(path: str) -> str:
4645
@project_app.command("list")
4746
def list_projects() -> None:
4847
"""List all Basic Memory projects."""
49-
# Use API to list projects
50-
try:
51-
auth_headers = {}
52-
if config.cloud_mode_enabled:
53-
auth_headers = asyncio.run(get_authenticated_headers())
5448

55-
response = asyncio.run(call_get(client, "/projects/projects", headers=auth_headers))
56-
result = ProjectList.model_validate(response.json())
49+
async def _list_projects():
50+
async with get_client() as client:
51+
response = await call_get(client, "/projects/projects")
52+
return ProjectList.model_validate(response.json())
53+
54+
try:
55+
result = asyncio.run(_list_projects())
5756

5857
table = Table(title="Basic Memory Projects")
5958
table.add_column("Name", style="cyan")
@@ -79,16 +78,14 @@ def add_project_cloud(
7978
) -> None:
8079
"""Add a new project to Basic Memory Cloud"""
8180

82-
try:
83-
auth_headers = asyncio.run(get_authenticated_headers())
84-
85-
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
86-
87-
response = asyncio.run(
88-
call_post(client, "/projects/projects", json=data, headers=auth_headers)
89-
)
90-
result = ProjectStatusResponse.model_validate(response.json())
81+
async def _add_project():
82+
async with get_client() as client:
83+
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
84+
response = await call_post(client, "/projects/projects", json=data)
85+
return ProjectStatusResponse.model_validate(response.json())
9186

87+
try:
88+
result = asyncio.run(_add_project())
9289
console.print(f"[green]{result.message}[/green]")
9390
except Exception as e:
9491
console.print(f"[red]Error adding project: {str(e)}[/red]")
@@ -109,12 +106,14 @@ def add_project(
109106
# Resolve to absolute path
110107
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
111108

112-
try:
113-
data = {"name": name, "path": resolved_path, "set_default": set_default}
114-
115-
response = asyncio.run(call_post(client, "/projects/projects", json=data))
116-
result = ProjectStatusResponse.model_validate(response.json())
109+
async def _add_project():
110+
async with get_client() as client:
111+
data = {"name": name, "path": resolved_path, "set_default": set_default}
112+
response = await call_post(client, "/projects/projects", json=data)
113+
return ProjectStatusResponse.model_validate(response.json())
117114

115+
try:
116+
result = asyncio.run(_add_project())
118117
console.print(f"[green]{result.message}[/green]")
119118
except Exception as e:
120119
console.print(f"[red]Error adding project: {str(e)}[/red]")
@@ -130,17 +129,15 @@ def remove_project(
130129
name: str = typer.Argument(..., help="Name of the project to remove"),
131130
) -> None:
132131
"""Remove a project."""
133-
try:
134-
auth_headers = {}
135-
if config.cloud_mode_enabled:
136-
auth_headers = asyncio.run(get_authenticated_headers())
137132

138-
project_permalink = generate_permalink(name)
139-
response = asyncio.run(
140-
call_delete(client, f"/projects/{project_permalink}", headers=auth_headers)
141-
)
142-
result = ProjectStatusResponse.model_validate(response.json())
133+
async def _remove_project():
134+
async with get_client() as client:
135+
project_permalink = generate_permalink(name)
136+
response = await call_delete(client, f"/projects/{project_permalink}")
137+
return ProjectStatusResponse.model_validate(response.json())
143138

139+
try:
140+
result = asyncio.run(_remove_project())
144141
console.print(f"[green]{result.message}[/green]")
145142
except Exception as e:
146143
console.print(f"[red]Error removing project: {str(e)}[/red]")
@@ -157,11 +154,15 @@ def set_default_project(
157154
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
158155
) -> None:
159156
"""Set the default project when 'config.default_project_mode' is set."""
160-
try:
161-
project_permalink = generate_permalink(name)
162-
response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
163-
result = ProjectStatusResponse.model_validate(response.json())
164157

158+
async def _set_default():
159+
async with get_client() as client:
160+
project_permalink = generate_permalink(name)
161+
response = await call_put(client, f"/projects/{project_permalink}/default")
162+
return ProjectStatusResponse.model_validate(response.json())
163+
164+
try:
165+
result = asyncio.run(_set_default())
165166
console.print(f"[green]{result.message}[/green]")
166167
except Exception as e:
167168
console.print(f"[red]Error setting default project: {str(e)}[/red]")
@@ -170,12 +171,14 @@ def set_default_project(
170171
@project_app.command("sync-config")
171172
def synchronize_projects() -> None:
172173
"""Synchronize project config between configuration file and database."""
173-
# Call the API to synchronize projects
174174

175-
try:
176-
response = asyncio.run(call_post(client, "/projects/config/sync"))
177-
result = ProjectStatusResponse.model_validate(response.json())
175+
async def _sync_config():
176+
async with get_client() as client:
177+
response = await call_post(client, "/projects/config/sync")
178+
return ProjectStatusResponse.model_validate(response.json())
178179

180+
try:
181+
result = asyncio.run(_sync_config())
179182
console.print(f"[green]{result.message}[/green]")
180183
except Exception as e: # pragma: no cover
181184
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
@@ -190,17 +193,19 @@ def move_project(
190193
# Resolve to absolute path
191194
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
192195

193-
try:
194-
data = {"path": resolved_path}
195-
196-
project_permalink = generate_permalink(name)
196+
async def _move_project():
197+
async with get_client() as client:
198+
data = {"path": resolved_path}
199+
project_permalink = generate_permalink(name)
197200

198-
# TODO fix route to use ProjectPathDep
199-
response = asyncio.run(
200-
call_patch(client, f"/{name}/project/{project_permalink}", json=data)
201-
)
202-
result = ProjectStatusResponse.model_validate(response.json())
201+
# TODO fix route to use ProjectPathDep
202+
response = await call_patch(
203+
client, f"/{name}/project/{project_permalink}", json=data
204+
)
205+
return ProjectStatusResponse.model_validate(response.json())
203206

207+
try:
208+
result = asyncio.run(_move_project())
204209
console.print(f"[green]{result.message}[/green]")
205210

206211
# Show important file movement reminder

src/basic_memory/cli/commands/status.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@
1212
from rich.tree import Tree
1313

1414
from basic_memory.cli.app import app
15-
from basic_memory.cli.commands.cloud import get_authenticated_headers
16-
from basic_memory.mcp.async_client import client
15+
from basic_memory.mcp.async_client import get_client
1716
from basic_memory.mcp.tools.utils import call_post
1817
from basic_memory.schemas import SyncReportResponse
1918
from basic_memory.mcp.project_context import get_active_project
@@ -130,20 +129,12 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p
130129
"""Check sync status of files vs database."""
131130

132131
try:
133-
from basic_memory.config import ConfigManager
132+
async with get_client() as client:
133+
project_item = await get_active_project(client, project, None)
134+
response = await call_post(client, f"{project_item.project_url}/project/status")
135+
sync_report = SyncReportResponse.model_validate(response.json())
134136

135-
config = ConfigManager().config
136-
auth_headers = {}
137-
if config.cloud_mode_enabled:
138-
auth_headers = await get_authenticated_headers()
139-
140-
project_item = await get_active_project(client, project, None)
141-
response = await call_post(
142-
client, f"{project_item.project_url}/project/status", headers=auth_headers
143-
)
144-
sync_report = SyncReportResponse.model_validate(response.json())
145-
146-
display_changes(project_item.name, "Status", sync_report, verbose)
137+
display_changes(project_item.name, "Status", sync_report, verbose)
147138

148139
except (ValueError, ToolError) as e:
149140
console.print(f"[red]✗ Error: {e}[/red]")

0 commit comments

Comments
 (0)