Skip to content

Commit d383a52

Browse files
committed
fix(cli): address workspace review feedback
Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent fa408fe commit d383a52

5 files changed

Lines changed: 251 additions & 90 deletions

File tree

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66

77
from basic_memory.cli.commands.command_utils import run_with_cleanup
88
from basic_memory.config import ConfigManager
9-
from basic_memory.mcp.project_context import (
10-
_workspace_choices,
11-
_workspace_matches_identifier,
12-
_workspace_selection_choices,
13-
get_available_workspaces,
9+
from basic_memory.mcp.project_context import get_available_workspaces
10+
from basic_memory.schemas.cloud import (
11+
format_workspace_choices,
12+
format_workspace_selection_choices,
13+
workspace_matches_identifier,
1414
)
1515

1616
console = Console()
@@ -92,11 +92,11 @@ async def _list():
9292
console.print("[yellow]No accessible workspaces found.[/yellow]")
9393
raise typer.Exit(1)
9494

95-
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, identifier)]
95+
matches = [ws for ws in workspaces if workspace_matches_identifier(ws, identifier)]
9696

9797
if not matches:
9898
console.print(f"[red]Error: Workspace '{identifier}' not found[/red]")
99-
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
99+
console.print(f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]")
100100
raise typer.Exit(1)
101101

102102
if len(matches) > 1:
@@ -105,7 +105,7 @@ async def _list():
105105
)
106106
console.print(
107107
"[dim]Choose one of these matching workspaces by slug:\n"
108-
f"{_workspace_selection_choices(matches)}[/dim]"
108+
f"{format_workspace_selection_choices(matches)}[/dim]"
109109
)
110110
raise typer.Exit(1)
111111

src/basic_memory/cli/commands/project.py

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
CloudTenantIndexStatusResponse,
3535
ProjectVisibility,
3636
WorkspaceInfo,
37+
format_workspace_choices,
38+
format_workspace_selection_choices,
39+
workspace_matches_identifier,
3740
)
3841
from basic_memory.schemas.project_info import ProjectItem, ProjectList
3942
from basic_memory.utils import generate_permalink, normalize_project_path
@@ -284,27 +287,24 @@ def _normalize_project_visibility(visibility: str | None) -> ProjectVisibility:
284287
def _resolve_workspace_id(config, workspace: str | None) -> str | None:
285288
"""Resolve a workspace name, slug, type, or tenant_id to a tenant_id."""
286289
from basic_memory.mcp.project_context import (
287-
_workspace_choices,
288-
_workspace_matches_identifier,
289-
_workspace_selection_choices,
290290
get_available_workspaces,
291291
)
292292

293293
if workspace is not None:
294294
workspaces = run_with_cleanup(get_available_workspaces())
295-
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
295+
matches = [ws for ws in workspaces if workspace_matches_identifier(ws, workspace)]
296296
if not matches:
297297
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
298298
if workspaces:
299-
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
299+
console.print(f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]")
300300
raise typer.Exit(1)
301301
if len(matches) > 1:
302302
console.print(
303303
f"[red]Error: Workspace '{workspace}' matches multiple workspaces.[/red]"
304304
)
305305
console.print(
306306
"[dim]Choose one of these matching workspaces by slug:\n"
307-
f"{_workspace_selection_choices(matches)}[/dim]"
307+
f"{format_workspace_selection_choices(matches)}[/dim]"
308308
)
309309
raise typer.Exit(1)
310310
return matches[0].tenant_id
@@ -347,6 +347,8 @@ async def _list_projects(ws: str | None = None):
347347

348348
try:
349349
config = ConfigManager().config
350+
workspace_filter = workspace
351+
workspace_filter_requested = workspace_filter is not None
350352

351353
local_result: ProjectList | None = None
352354
cloud_results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
@@ -355,47 +357,55 @@ async def _list_projects(ws: str | None = None):
355357
cloud_workspace_error: Exception | None = None
356358
failed_cloud_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
357359

358-
def _fetch_cloud_workspace_results() -> list[tuple[WorkspaceInfo | None, ProjectList]]:
359-
nonlocal available_cloud_workspaces, cloud_workspace_error
360-
360+
def _fetch_cloud_workspace_results() -> tuple[
361+
list[tuple[WorkspaceInfo | None, ProjectList]],
362+
list[WorkspaceInfo],
363+
Exception | None,
364+
list[tuple[WorkspaceInfo, Exception]],
365+
]:
361366
from basic_memory.mcp.project_context import (
362-
_workspace_choices,
363-
_workspace_matches_identifier,
364-
_workspace_selection_choices,
365367
get_available_workspaces,
366368
)
367369

368370
try:
369371
workspaces = run_with_cleanup(get_available_workspaces())
370-
available_cloud_workspaces = workspaces
371372
except Exception as exc:
372-
cloud_workspace_error = exc
373-
fallback_workspace = workspace or config.default_workspace
374-
return [(None, run_with_cleanup(_list_projects(fallback_workspace)))]
373+
fallback_workspace = workspace_filter or config.default_workspace
374+
return (
375+
[(None, run_with_cleanup(_list_projects(fallback_workspace)))],
376+
[],
377+
exc,
378+
[],
379+
)
375380

376381
selected_workspaces = workspaces
377-
if workspace is not None:
378-
matches = [ws for ws in workspaces if _workspace_matches_identifier(ws, workspace)]
382+
if workspace_filter is not None:
383+
matches = [
384+
ws for ws in workspaces if workspace_matches_identifier(ws, workspace_filter)
385+
]
379386
if not matches:
380-
console.print(f"[red]Error: Workspace '{workspace}' not found[/red]")
387+
console.print(f"[red]Error: Workspace '{workspace_filter}' not found[/red]")
381388
if workspaces:
382-
console.print(f"[dim]Available:\n{_workspace_choices(workspaces)}[/dim]")
389+
console.print(
390+
f"[dim]Available:\n{format_workspace_choices(workspaces)}[/dim]"
391+
)
383392
raise typer.Exit(1)
384393
if len(matches) > 1:
385394
console.print(
386-
f"[red]Error: Workspace '{workspace}' matches multiple workspaces.[/red]"
395+
f"[red]Error: Workspace '{workspace_filter}' matches multiple workspaces.[/red]"
387396
)
388397
console.print(
389398
"[dim]Choose one of these matching workspaces by slug:\n"
390-
f"{_workspace_selection_choices(matches)}[/dim]"
399+
f"{format_workspace_selection_choices(matches)}[/dim]"
391400
)
392401
raise typer.Exit(1)
393402
selected_workspaces = matches
394403

395404
if not selected_workspaces:
396-
return []
405+
return [], workspaces, None, []
397406

398407
results: list[tuple[WorkspaceInfo | None, ProjectList]] = []
408+
failed_workspaces: list[tuple[WorkspaceInfo, Exception]] = []
399409
for cloud_workspace in selected_workspaces:
400410
try:
401411
results.append(
@@ -405,17 +415,22 @@ def _fetch_cloud_workspace_results() -> list[tuple[WorkspaceInfo | None, Project
405415
)
406416
)
407417
except Exception as exc:
408-
failed_cloud_workspaces.append((cloud_workspace, exc))
418+
failed_workspaces.append((cloud_workspace, exc))
409419

410-
if not results and failed_cloud_workspaces:
411-
raise failed_cloud_workspaces[0][1]
420+
if not results and failed_workspaces:
421+
raise failed_workspaces[0][1]
412422

413-
return results
423+
return results, workspaces, None, failed_workspaces
414424

415425
if cloud:
416426
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
417427
with force_routing(cloud=True):
418-
cloud_results = _fetch_cloud_workspace_results()
428+
(
429+
cloud_results,
430+
available_cloud_workspaces,
431+
cloud_workspace_error,
432+
failed_cloud_workspaces,
433+
) = _fetch_cloud_workspace_results()
419434
elif local:
420435
with force_routing(local=True):
421436
local_result = run_with_cleanup(_list_projects())
@@ -428,7 +443,12 @@ def _fetch_cloud_workspace_results() -> list[tuple[WorkspaceInfo | None, Project
428443
try:
429444
with console.status("[bold blue]Fetching cloud projects...", spinner="dots"):
430445
with force_routing(cloud=True):
431-
cloud_results = _fetch_cloud_workspace_results()
446+
(
447+
cloud_results,
448+
available_cloud_workspaces,
449+
cloud_workspace_error,
450+
failed_cloud_workspaces,
451+
) = _fetch_cloud_workspace_results()
432452
except typer.Exit:
433453
raise
434454
except Exception as exc: # pragma: no cover
@@ -491,7 +511,7 @@ def _workspace_priority(row_key: tuple[str | None, str]) -> tuple[bool, int, str
491511

492512
def _select_attached_row_key(
493513
permalink: str, entry: ProjectEntry | None
494-
) -> tuple[str | None, str]:
514+
) -> tuple[str | None, str] | None:
495515
"""Choose the single row that owns local config/default/sync state."""
496516
cloud_keys = cloud_keys_by_permalink.get(permalink, [])
497517
if not cloud_keys:
@@ -517,8 +537,10 @@ def _select_attached_row_key(
517537
if row_key[0] == workspace_id:
518538
return row_key
519539

520-
if workspace is not None and preferred_workspace_ids:
521-
return (preferred_workspace_ids[0], permalink)
540+
if workspace_filter_requested and preferred_workspace_ids:
541+
# A filtered list can exclude the workspace that owns local config state.
542+
# In that case, do not attach local/default/sync state to another workspace row.
543+
return None
522544

523545
default_workspace_keys = [
524546
row_key
@@ -534,7 +556,7 @@ def _select_attached_row_key(
534556

535557
return sorted(cloud_keys, key=_workspace_priority)[0]
536558

537-
attached_row_by_permalink: dict[str, tuple[str | None, str]] = {}
559+
attached_row_by_permalink: dict[str, tuple[str | None, str] | None] = {}
538560
for permalink in set(local_projects_by_permalink) | set(configured_names_by_permalink):
539561
configured_name = configured_names_by_permalink.get(permalink)
540562
local_project = local_projects_by_permalink.get(permalink)
@@ -633,7 +655,7 @@ def _select_attached_row_key(
633655
if display_name:
634656
row_data["display_name"] = display_name
635657
if cloud_project is not None and cloud_ws_name:
636-
row_data["workspace"] = cloud_ws_name or ""
658+
row_data["workspace"] = cloud_ws_name
637659
if cloud_ws_type:
638660
row_data["workspace_type"] = cloud_ws_type
639661

src/basic_memory/mcp/project_context.py

Lines changed: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import asyncio
1212
from contextlib import asynccontextmanager, nullcontext
1313
from dataclasses import dataclass, field
14-
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
14+
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Sequence, Tuple, cast
1515
from uuid import UUID
1616

1717
from httpx import AsyncClient
@@ -25,7 +25,13 @@
2525
import logfire
2626
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectMode, has_cloud_credentials
2727
from basic_memory.project_resolver import ProjectResolver
28-
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
28+
from basic_memory.schemas.cloud import (
29+
WorkspaceInfo,
30+
WorkspaceListResponse,
31+
format_workspace_choices,
32+
format_workspace_selection_choices,
33+
workspace_matches_identifier,
34+
)
2935
from basic_memory.schemas.project_info import ProjectItem, ProjectList
3036
from basic_memory.schemas.v2 import ProjectResolveResponse
3137
from basic_memory.schemas.memory import memory_url_path
@@ -299,45 +305,6 @@ async def get_project_names(client: AsyncClient, headers: HeaderTypes | None = N
299305
return [project.name for project in project_list.projects]
300306

301307

302-
def _workspace_matches_identifier(workspace: WorkspaceInfo, identifier: str) -> bool:
303-
"""Return True when identifier matches workspace tenant_id, slug, name, or type."""
304-
if workspace.tenant_id == identifier:
305-
return True
306-
if workspace.slug.casefold() == identifier.casefold():
307-
return True
308-
if workspace.workspace_type.casefold() == identifier.casefold():
309-
return True
310-
return workspace.name.lower() == identifier.lower()
311-
312-
313-
def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
314-
"""Format deterministic workspace choices for prompt-style errors."""
315-
return "\n".join(
316-
[
317-
(
318-
f"- {item.name} "
319-
f"(slug={item.slug}, type={item.workspace_type}, "
320-
f"role={item.role}, tenant_id={item.tenant_id})"
321-
)
322-
for item in workspaces
323-
]
324-
)
325-
326-
327-
def _workspace_selection_choices(workspaces: list[WorkspaceInfo]) -> str:
328-
"""Format matching workspaces with copyable unique identifiers first."""
329-
return "\n".join(
330-
[
331-
(
332-
f"- {item.name} ({item.workspace_type}, role={item.role})\n"
333-
f" workspace: {item.slug}\n"
334-
f" tenant_id: {item.tenant_id}"
335-
)
336-
for item in workspaces
337-
]
338-
)
339-
340-
341308
def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex | None:
342309
"""Deserialize a cached workspace project index from MCP context state."""
343310
if not isinstance(raw, dict):
@@ -640,7 +607,7 @@ async def _resolve_workspace_segments(
640607
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
641608

642609

643-
def _format_qualified_choices(entries: tuple[WorkspaceProjectEntry, ...]) -> str:
610+
def _format_qualified_choices(entries: Sequence[WorkspaceProjectEntry]) -> str:
644611
"""Format qualified project choices for collision errors."""
645612
return " or ".join(entry.qualified_name for entry in entries)
646613

@@ -869,7 +836,7 @@ async def resolve_workspace_project_identifier(
869836
)
870837
return matches[0]
871838

872-
matches = index.entries_by_permalink.get(project_permalink, ())
839+
matches = list(index.entries_by_permalink.get(project_permalink, ()))
873840
if not matches:
874841
failed_note = ""
875842
if index.failed_workspaces:
@@ -1003,7 +970,7 @@ async def resolve_workspace_parameter(
1003970
cached_raw = await context.get_state("active_workspace")
1004971
if isinstance(cached_raw, dict):
1005972
cached_workspace = WorkspaceInfo.model_validate(cached_raw)
1006-
if workspace is None or _workspace_matches_identifier(cached_workspace, workspace):
973+
if workspace is None or workspace_matches_identifier(cached_workspace, workspace):
1007974
logger.debug(
1008975
f"Using cached workspace from context: {cached_workspace.tenant_id}"
1009976
)
@@ -1020,18 +987,18 @@ async def resolve_workspace_parameter(
1020987

1021988
if workspace:
1022989
matches = [
1023-
item for item in workspaces if _workspace_matches_identifier(item, workspace)
990+
item for item in workspaces if workspace_matches_identifier(item, workspace)
1024991
]
1025992
if not matches:
1026993
raise ValueError(
1027994
f"Workspace '{workspace}' was not found.\n"
1028-
f"Available workspaces:\n{_workspace_choices(workspaces)}"
995+
f"Available workspaces:\n{format_workspace_choices(workspaces)}"
1029996
)
1030997
if len(matches) > 1:
1031998
raise ValueError(
1032999
f"Workspace '{workspace}' matches multiple workspaces. "
10331000
"Choose one of these matching workspaces by slug or tenant_id:\n"
1034-
f"{_workspace_selection_choices(matches)}"
1001+
f"{format_workspace_selection_choices(matches)}"
10351002
)
10361003
selected_workspace = matches[0]
10371004
elif len(workspaces) == 1:
@@ -1040,7 +1007,7 @@ async def resolve_workspace_parameter(
10401007
raise ValueError(
10411008
"Multiple workspaces are available. Ask the user which workspace to use, then retry "
10421009
"with the 'workspace' argument set to the tenant_id or unique name/slug/type.\n"
1043-
f"Available workspaces:\n{_workspace_choices(workspaces)}"
1010+
f"Available workspaces:\n{format_workspace_choices(workspaces)}"
10441011
)
10451012

10461013
await _set_cached_active_workspace(context, selected_workspace)

0 commit comments

Comments
 (0)