Skip to content

Commit 706fd50

Browse files
authored
Merge branch 'main' into feat-project-display-name
2 parents fedcbff + f268329 commit 706fd50

29 files changed

Lines changed: 902 additions & 99 deletions

src/basic_memory/cli/commands/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
"""CLI commands for basic-memory."""
22

33
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
4-
from . import import_claude_projects, import_chatgpt, tool, project, format, schema, watch
4+
from . import (
5+
import_claude_projects,
6+
import_chatgpt,
7+
tool,
8+
project,
9+
format,
10+
schema,
11+
watch,
12+
workspace,
13+
)
514

615
__all__ = [
716
"status",
@@ -17,4 +26,5 @@
1726
"format",
1827
"schema",
1928
"watch",
29+
"workspace",
2030
]

src/basic_memory/cli/commands/tool.py

Lines changed: 89 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@
1313
from basic_memory.cli.commands.command_utils import run_with_cleanup
1414
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
1515
from basic_memory.config import ConfigManager
16-
from basic_memory.mcp.async_client import get_client
1716
from basic_memory.mcp.clients import KnowledgeClient, ResourceClient
18-
from basic_memory.mcp.project_context import get_active_project
17+
from basic_memory.mcp.project_context import get_project_client
1918
from basic_memory.mcp.tools.utils import call_get
2019
from basic_memory.schemas.base import Entity, TimeFrame
2120
from basic_memory.schemas.memory import GraphContext, MemoryUrl, memory_url_path
@@ -25,9 +24,6 @@
2524
from basic_memory.mcp.prompts.continue_conversation import (
2625
continue_conversation as mcp_continue_conversation,
2726
)
28-
from basic_memory.mcp.prompts.recent_activity import (
29-
recent_activity_prompt as recent_activity_prompt,
30-
)
3127
from basic_memory.mcp.tools import build_context as mcp_build_context
3228
from basic_memory.mcp.tools import edit_note as mcp_edit_note
3329
from basic_memory.mcp.tools import read_note as mcp_read_note
@@ -99,15 +95,26 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None
9995

10096

10197
async def _write_note_json(
102-
title: str, content: str, folder: str, project_name: Optional[str], tags: Optional[List[str]]
98+
title: str,
99+
content: str,
100+
folder: str,
101+
project_name: Optional[str],
102+
workspace: Optional[str],
103+
tags: Optional[List[str]],
103104
) -> dict:
104105
"""Write a note and return structured JSON metadata."""
105106
# Use the MCP tool to create/update the entity (handles create-or-update logic)
106-
await mcp_write_note.fn(title, content, folder, project_name, tags)
107+
await mcp_write_note.fn(
108+
title=title,
109+
content=content,
110+
directory=folder,
111+
project=project_name,
112+
workspace=workspace,
113+
tags=tags,
114+
)
107115

108116
# Resolve the entity to get metadata back
109-
async with get_client(project_name=project_name) as client:
110-
active_project = await get_active_project(client, project_name)
117+
async with get_project_client(project_name, workspace) as (client, active_project):
111118
knowledge_client = KnowledgeClient(client, active_project.external_id)
112119

113120
entity = Entity(title=title, directory=folder)
@@ -125,11 +132,14 @@ async def _write_note_json(
125132

126133

127134
async def _read_note_json(
128-
identifier: str, project_name: Optional[str], page: int, page_size: int
135+
identifier: str,
136+
project_name: Optional[str],
137+
workspace: Optional[str],
138+
page: int,
139+
page_size: int,
129140
) -> dict:
130141
"""Read a note and return structured JSON with content and metadata."""
131-
async with get_client(project_name=project_name) as client:
132-
active_project = await get_active_project(client, project_name)
142+
async with get_project_client(project_name, workspace) as (client, active_project):
133143
knowledge_client = KnowledgeClient(client, active_project.external_id)
134144
resource_client = ResourceClient(client, active_project.external_id)
135145

@@ -146,7 +156,10 @@ async def _read_note_json(
146156
from basic_memory.mcp.tools.search import search_notes as mcp_search_tool
147157

148158
title_results = await mcp_search_tool.fn(
149-
query=identifier, search_type="title", project=project_name
159+
query=identifier,
160+
search_type="title",
161+
project=project_name,
162+
workspace=workspace,
150163
)
151164
if title_results and hasattr(title_results, "results") and title_results.results:
152165
result = title_results.results[0]
@@ -172,13 +185,13 @@ async def _edit_note_json(
172185
operation: str,
173186
content: str,
174187
project_name: Optional[str],
188+
workspace: Optional[str],
175189
section: Optional[str],
176190
find_text: Optional[str],
177191
expected_replacements: int,
178192
) -> dict:
179193
"""Edit a note and return structured JSON metadata."""
180-
async with get_client(project_name=project_name) as client:
181-
active_project = await get_active_project(client, project_name)
194+
async with get_project_client(project_name, workspace) as (client, active_project):
182195
knowledge_client = KnowledgeClient(client, active_project.external_id)
183196

184197
entity_id = await knowledge_client.resolve_entity(identifier)
@@ -227,11 +240,12 @@ async def _recent_activity_json(
227240
depth: Optional[int],
228241
timeframe: Optional[TimeFrame],
229242
project_name: Optional[str] = None,
243+
workspace: Optional[str] = None,
230244
page: int = 1,
231245
page_size: int = 50,
232246
) -> list:
233247
"""Get recent activity and return structured JSON list."""
234-
async with get_client(project_name=project_name) as client:
248+
async with get_project_client(project_name, workspace) as (client, active_project):
235249
# Build query params matching the MCP tool's logic
236250
params: dict = {"page": page, "page_size": page_size, "max_related": 10}
237251
if depth:
@@ -241,7 +255,6 @@ async def _recent_activity_json(
241255
if type:
242256
params["type"] = [t.value for t in type]
243257

244-
active_project = await get_active_project(client, project_name)
245258
response = await call_get(
246259
client,
247260
f"/v2/projects/{active_project.external_id}/memory/recent",
@@ -275,6 +288,10 @@ def write_note(
275288
help="The project to write to. If not provided, the default project will be used."
276289
),
277290
] = None,
291+
workspace: Annotated[
292+
Optional[str],
293+
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
294+
] = None,
278295
content: Annotated[
279296
Optional[str],
280297
typer.Option(
@@ -362,12 +379,19 @@ def write_note(
362379
with force_routing(local=local, cloud=cloud):
363380
if format == "json":
364381
result = run_with_cleanup(
365-
_write_note_json(title, content, folder, project_name, tags)
382+
_write_note_json(title, content, folder, project_name, workspace, tags)
366383
)
367384
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
368385
else:
369386
note = run_with_cleanup(
370-
mcp_write_note.fn(title, content, folder, project_name, tags)
387+
mcp_write_note.fn(
388+
title=title,
389+
content=content,
390+
directory=folder,
391+
project=project_name,
392+
workspace=workspace,
393+
tags=tags,
394+
)
371395
)
372396
rprint(note)
373397
except ValueError as e:
@@ -389,6 +413,10 @@ def read_note(
389413
help="The project to use for the note. If not provided, the default project will be used."
390414
),
391415
] = None,
416+
workspace: Annotated[
417+
Optional[str],
418+
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
419+
] = None,
392420
page: int = 1,
393421
page_size: int = 10,
394422
format: str = typer.Option("text", "--format", help="Output format: text or json"),
@@ -429,15 +457,23 @@ def read_note(
429457
with force_routing(local=local, cloud=cloud):
430458
if format == "json":
431459
result = run_with_cleanup(
432-
_read_note_json(identifier, project_name, page, page_size)
460+
_read_note_json(identifier, project_name, workspace, page, page_size)
433461
)
434462
stripped_content, parsed_frontmatter = _parse_opening_frontmatter(result["content"])
435463
result["frontmatter"] = parsed_frontmatter
436464
if strip_frontmatter:
437465
result["content"] = stripped_content
438466
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
439467
else:
440-
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
468+
note = run_with_cleanup(
469+
mcp_read_note.fn(
470+
identifier=identifier,
471+
project=project_name,
472+
workspace=workspace,
473+
page=page,
474+
page_size=page_size,
475+
)
476+
)
441477
if strip_frontmatter:
442478
note, _ = _parse_opening_frontmatter(note)
443479
rprint(note)
@@ -462,6 +498,10 @@ def edit_note(
462498
help="The project to edit. If not provided, the default project will be used."
463499
),
464500
] = None,
501+
workspace: Annotated[
502+
Optional[str],
503+
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
504+
] = None,
465505
find_text: Annotated[
466506
Optional[str], typer.Option("--find-text", help="Text to find for find_replace operation")
467507
] = None,
@@ -509,6 +549,7 @@ def edit_note(
509549
operation=operation,
510550
content=content,
511551
project_name=project_name,
552+
workspace=workspace,
512553
section=section,
513554
find_text=find_text,
514555
expected_replacements=expected_replacements,
@@ -522,6 +563,7 @@ def edit_note(
522563
operation=operation,
523564
content=content,
524565
project=project_name,
566+
workspace=workspace,
525567
section=section,
526568
find_text=find_text,
527569
expected_replacements=expected_replacements,
@@ -547,6 +589,10 @@ def build_context(
547589
Optional[str],
548590
typer.Option(help="The project to use. If not provided, the default project will be used."),
549591
] = None,
592+
workspace: Annotated[
593+
Optional[str],
594+
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
595+
] = None,
550596
depth: Optional[int] = 1,
551597
timeframe: Optional[TimeFrame] = "7d",
552598
page: int = 1,
@@ -582,6 +628,7 @@ def build_context(
582628
result = run_with_cleanup(
583629
mcp_build_context.fn(
584630
project=project_name,
631+
workspace=workspace,
585632
url=url,
586633
depth=depth,
587634
timeframe=timeframe,
@@ -609,6 +656,10 @@ def recent_activity(
609656
Optional[str],
610657
typer.Option(help="The project to use. If not provided, the default project will be used."),
611658
] = None,
659+
workspace: Annotated[
660+
Optional[str],
661+
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
662+
] = None,
612663
depth: Optional[int] = 1,
613664
timeframe: Optional[TimeFrame] = "7d",
614665
page: int = typer.Option(1, "--page", help="Page number for pagination (JSON format)"),
@@ -642,7 +693,15 @@ def recent_activity(
642693
with force_routing(local=local, cloud=cloud):
643694
if format == "json":
644695
result = run_with_cleanup(
645-
_recent_activity_json(type, depth, timeframe, project_name, page, page_size)
696+
_recent_activity_json(
697+
type=type,
698+
depth=depth,
699+
timeframe=timeframe,
700+
project_name=project_name,
701+
workspace=workspace,
702+
page=page,
703+
page_size=page_size,
704+
)
646705
)
647706
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
648707
else:
@@ -652,6 +711,7 @@ def recent_activity(
652711
depth=depth,
653712
timeframe=timeframe,
654713
project=project_name,
714+
workspace=workspace,
655715
)
656716
)
657717
# The tool returns a formatted string directly
@@ -682,6 +742,10 @@ def search_notes(
682742
help="The project to use for the note. If not provided, the default project will be used."
683743
),
684744
] = None,
745+
workspace: Annotated[
746+
Optional[str],
747+
typer.Option(help="Cloud workspace tenant ID or unique name to route this request."),
748+
] = None,
685749
after_date: Annotated[
686750
Optional[str],
687751
typer.Option("--after_date", help="Search results after date, eg. '2d', '1 week'"),
@@ -793,8 +857,9 @@ def search_notes(
793857
with force_routing(local=local, cloud=cloud):
794858
results = run_with_cleanup(
795859
mcp_search.fn(
796-
query or "",
797-
project_name,
860+
query=query or "",
861+
project=project_name,
862+
workspace=workspace,
798863
search_type=search_type,
799864
page=page,
800865
after_date=after_date,
@@ -857,22 +922,3 @@ def continue_conversation(
857922
typer.echo(f"Error continuing conversation: {e}", err=True)
858923
raise typer.Exit(1)
859924
raise
860-
861-
862-
# @tool_app.command(name="show-recent-activity")
863-
# def show_recent_activity(
864-
# timeframe: Annotated[
865-
# str, typer.Option(help="How far back to look for activity")
866-
# ] = "7d",
867-
# ):
868-
# """Prompt to show recent activity."""
869-
# try:
870-
# # Prompt functions return formatted strings directly
871-
# session = asyncio.run(recent_activity_prompt(timeframe=timeframe))
872-
# rprint(session)
873-
# except Exception as e: # pragma: no cover
874-
# if not isinstance(e, typer.Exit):
875-
# logger.exception("Error continuing conversation", e)
876-
# typer.echo(f"Error continuing conversation: {e}", err=True)
877-
# raise typer.Exit(1)
878-
# raise
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Workspace commands for Basic Memory cloud workspaces."""
2+
3+
import typer
4+
from rich.console import Console
5+
from rich.table import Table
6+
7+
from basic_memory.cli.app import app
8+
from basic_memory.cli.commands.command_utils import run_with_cleanup
9+
from basic_memory.mcp.project_context import get_available_workspaces
10+
11+
console = Console()
12+
13+
workspace_app = typer.Typer(help="Manage cloud workspaces")
14+
app.add_typer(workspace_app, name="workspace")
15+
16+
17+
@workspace_app.command("list")
18+
def list_workspaces() -> None:
19+
"""List cloud workspaces available to the current OAuth session."""
20+
21+
async def _list():
22+
return await get_available_workspaces()
23+
24+
try:
25+
workspaces = run_with_cleanup(_list())
26+
except RuntimeError as exc:
27+
console.print(f"[red]Error: {exc}[/red]")
28+
raise typer.Exit(1)
29+
except Exception as exc: # pragma: no cover
30+
console.print(f"[red]Error listing workspaces: {exc}[/red]")
31+
raise typer.Exit(1)
32+
33+
if not workspaces:
34+
console.print("[yellow]No accessible workspaces found.[/yellow]")
35+
return
36+
37+
table = Table(title="Available Workspaces")
38+
table.add_column("Name", style="cyan")
39+
table.add_column("Type", style="blue")
40+
table.add_column("Role", style="green")
41+
table.add_column("Tenant ID", style="yellow")
42+
43+
for workspace in workspaces:
44+
table.add_row(
45+
workspace.name,
46+
workspace.workspace_type,
47+
workspace.role,
48+
workspace.tenant_id,
49+
)
50+
51+
console.print(table)
52+
53+
54+
@app.command("workspaces")
55+
def workspaces_alias() -> None:
56+
"""Alias for `bm workspace list`."""
57+
list_workspaces()

0 commit comments

Comments
 (0)