mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
clean up cli commands
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -8,8 +8,6 @@ from . import (
|
||||
project,
|
||||
format,
|
||||
schema,
|
||||
watch,
|
||||
workspace,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -25,6 +23,4 @@ __all__ = [
|
||||
"project",
|
||||
"format",
|
||||
"schema",
|
||||
"watch",
|
||||
"workspace",
|
||||
]
|
||||
|
||||
@@ -6,11 +6,14 @@ from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
|
||||
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
|
||||
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403
|
||||
from basic_memory.cli.commands.cloud.project_sync import * # noqa: F401,F403
|
||||
|
||||
# Register snapshot sub-command group
|
||||
from basic_memory.cli.commands.cloud.snapshot import snapshot_app
|
||||
from basic_memory.cli.commands.cloud.workspace import workspace_app
|
||||
|
||||
cloud_app.add_typer(snapshot_app, name="snapshot")
|
||||
cloud_app.add_typer(workspace_app, name="workspace")
|
||||
|
||||
# Register restore command (directly on cloud_app via decorator)
|
||||
from basic_memory.cli.commands.cloud.restore import restore # noqa: F401, E402
|
||||
|
||||
@@ -113,7 +113,7 @@ def status() -> None:
|
||||
has_credentials = bool(config.cloud_api_key) or tokens is not None
|
||||
if not has_credentials:
|
||||
console.print(
|
||||
"\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud set-key <key>[/dim]"
|
||||
"\n[dim]No cloud credentials found. Run: bm cloud login or bm cloud api-key save <key>[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -140,7 +140,7 @@ def status() -> None:
|
||||
except CloudAPIError as e:
|
||||
console.print(f"[yellow]Cloud health check failed: {e}[/yellow]")
|
||||
console.print(
|
||||
"[dim]Try re-authenticating with 'bm cloud login' or setting API key with 'bm cloud set-key'.[/dim]"
|
||||
"[dim]Try re-authenticating with 'bm cloud login' or setting API key with 'bm cloud api-key save'.[/dim]"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Unexpected health check error: {e}[/yellow]")
|
||||
@@ -219,17 +219,22 @@ def promo(enabled: bool = typer.Option(True, "--on/--off", help="Enable or disab
|
||||
console.print("[yellow]Cloud promo messages disabled[/yellow]")
|
||||
|
||||
|
||||
@cloud_app.command("set-key")
|
||||
def set_key(
|
||||
# --- API key management subcommand group ---
|
||||
|
||||
api_key_app = typer.Typer(help="Manage cloud API keys")
|
||||
cloud_app.add_typer(api_key_app, name="api-key")
|
||||
|
||||
|
||||
@api_key_app.command("save")
|
||||
def api_key_save(
|
||||
api_key: str = typer.Argument(..., help="API key (bmc_ prefixed) for cloud access"),
|
||||
) -> None:
|
||||
"""Save a cloud API key for per-project cloud routing.
|
||||
"""Save an existing API key to local config.
|
||||
|
||||
The API key is account-level and used by projects set to cloud mode.
|
||||
Create a key in the web app or use 'bm cloud create-key'.
|
||||
Use when you already have an API key (e.g., from the web app).
|
||||
|
||||
Example:
|
||||
bm cloud set-key bmc_abc123...
|
||||
bm cloud api-key save bmc_abc123...
|
||||
"""
|
||||
if not api_key.startswith("bmc_"):
|
||||
console.print("[red]Error: API key must start with 'bmc_'[/red]")
|
||||
@@ -245,17 +250,16 @@ def set_key(
|
||||
console.print("[dim]Set a project to cloud mode: bm project set-cloud <name>[/dim]")
|
||||
|
||||
|
||||
@cloud_app.command("create-key")
|
||||
def create_key(
|
||||
@api_key_app.command("create")
|
||||
def api_key_create(
|
||||
name: str = typer.Argument(..., help="Human-readable name for the API key"),
|
||||
) -> None:
|
||||
"""Create a new cloud API key and save it locally.
|
||||
"""Create a new API key via the cloud API and save it locally.
|
||||
|
||||
Requires active OAuth session (run 'bm cloud login' first).
|
||||
The key is created via the cloud API and saved to local config.
|
||||
|
||||
Example:
|
||||
bm cloud create-key "my-laptop"
|
||||
bm cloud api-key create "my-laptop"
|
||||
"""
|
||||
|
||||
async def _create_key():
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Cloud sync commands for Basic Memory projects.
|
||||
|
||||
Commands for syncing, bisyncing, and checking integrity between local and cloud
|
||||
project instances. These were previously in project.py but belong here since
|
||||
they are cloud-specific operations.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
RcloneError,
|
||||
SyncProject,
|
||||
get_project_bisync_state,
|
||||
project_bisync,
|
||||
project_check,
|
||||
project_sync,
|
||||
)
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing
|
||||
from basic_memory.config import ConfigManager, ProjectEntry
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post
|
||||
from basic_memory.schemas.project_info import ProjectItem, ProjectList
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
# --- Shared helpers ---
|
||||
|
||||
|
||||
def _has_cloud_credentials(config) -> bool:
|
||||
"""Return whether cloud credentials are available (API key or OAuth token)."""
|
||||
if config.cloud_api_key:
|
||||
return True
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
return auth.load_tokens() is not None
|
||||
|
||||
|
||||
def _require_cloud_credentials(config) -> None:
|
||||
"""Exit with actionable guidance when cloud credentials are missing."""
|
||||
if _has_cloud_credentials(config):
|
||||
return
|
||||
|
||||
console.print("[red]Error: cloud credentials are required for this command[/red]")
|
||||
console.print("[dim]Run 'bm cloud login' or 'bm cloud api-key save <key>' first[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
async def _get_cloud_project(name: str) -> ProjectItem | None:
|
||||
"""Fetch a project by name from the cloud API."""
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
return proj
|
||||
return None
|
||||
|
||||
|
||||
def _get_sync_project(
|
||||
name: str, config, project_data: ProjectItem
|
||||
) -> tuple[SyncProject, str | None]:
|
||||
"""Build a SyncProject and resolve local_sync_path from config.
|
||||
|
||||
Returns (sync_project, local_sync_path). Exits if no local_sync_path configured.
|
||||
"""
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.local_sync_path if sync_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
console.print(f"\nConfigure sync with: bm cloud sync-setup {name} ~/path/to/local")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
local_sync_path=local_sync_path,
|
||||
)
|
||||
return sync_project, local_sync_path
|
||||
|
||||
|
||||
# --- Commands ---
|
||||
|
||||
|
||||
@cloud_app.command("sync")
|
||||
def sync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to sync"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""One-way sync: local -> cloud (make cloud identical to local).
|
||||
|
||||
Example:
|
||||
bm cloud sync --name research
|
||||
bm cloud sync --name research --dry-run
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_cloud_project(name))
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project, local_sync_path = _get_sync_project(name, config, project_data)
|
||||
|
||||
# Run sync
|
||||
console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
|
||||
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{name} synced successfully[/green]")
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
if not dry_run:
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client() as client:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
|
||||
json={},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]{name} sync failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Sync error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("bisync")
|
||||
def bisync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to bisync"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""Two-way sync: local <-> cloud (bidirectional sync).
|
||||
|
||||
Examples:
|
||||
bm cloud bisync --name research --resync # First time
|
||||
bm cloud bisync --name research # Subsequent syncs
|
||||
bm cloud bisync --name research --dry-run # Preview changes
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_cloud_project(name))
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project, local_sync_path = _get_sync_project(name, config, project_data)
|
||||
|
||||
# Run bisync
|
||||
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
|
||||
success = project_bisync(
|
||||
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
|
||||
)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{name} bisync completed successfully[/green]")
|
||||
|
||||
# Update config — sync_entry is guaranteed non-None because
|
||||
# _get_sync_project validated local_sync_path (which comes from sync_entry)
|
||||
sync_entry = config.projects.get(name)
|
||||
assert sync_entry is not None
|
||||
sync_entry.last_sync = datetime.now()
|
||||
sync_entry.bisync_initialized = True
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
if not dry_run:
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client() as client:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
|
||||
json={},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]{name} bisync failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Bisync error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("check")
|
||||
def check_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to check"),
|
||||
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
|
||||
) -> None:
|
||||
"""Verify file integrity between local and cloud.
|
||||
|
||||
Example:
|
||||
bm cloud check --name research
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_cloud_project(name))
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
sync_project, local_sync_path = _get_sync_project(name, config, project_data)
|
||||
|
||||
# Run check
|
||||
console.print(f"[blue]Checking {name} integrity...[/blue]")
|
||||
match = project_check(sync_project, bucket_name, one_way=one_way)
|
||||
|
||||
if match:
|
||||
console.print(f"[green]{name} files match[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]!{name} has differences[/yellow]")
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Check error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("bisync-reset")
|
||||
def bisync_reset(
|
||||
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
|
||||
) -> None:
|
||||
"""Clear bisync state for a project.
|
||||
|
||||
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
|
||||
Useful when bisync gets into an inconsistent state or when remote path changes.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
try:
|
||||
state_path = get_project_bisync_state(name)
|
||||
|
||||
if not state_path.exists():
|
||||
console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
|
||||
return
|
||||
|
||||
# Remove the entire state directory
|
||||
shutil.rmtree(state_path)
|
||||
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@cloud_app.command("sync-setup")
|
||||
def setup_project_sync(
|
||||
name: str = typer.Argument(..., help="Project name"),
|
||||
local_path: str = typer.Argument(..., help="Local sync directory"),
|
||||
) -> None:
|
||||
"""Configure local sync for an existing cloud project.
|
||||
|
||||
Example:
|
||||
bm cloud sync-setup research ~/Documents/research
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
async def _verify_project_exists():
|
||||
"""Verify the project exists on cloud by listing all projects."""
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
project_list = response.json()
|
||||
project_names = [p["name"] for p in project_list["projects"]]
|
||||
if name not in project_names:
|
||||
raise ValueError(f"Project '{name}' not found on cloud")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Verify project exists on cloud
|
||||
with force_routing(cloud=True):
|
||||
run_with_cleanup(_verify_project_exists())
|
||||
|
||||
# Resolve and create local path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
|
||||
resolved_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update project entry with sync path
|
||||
entry = config.projects.get(name)
|
||||
if entry:
|
||||
entry.local_sync_path = resolved_path.as_posix()
|
||||
entry.bisync_initialized = False
|
||||
entry.last_sync = None
|
||||
else:
|
||||
config.projects[name] = ProjectEntry(
|
||||
path=resolved_path.as_posix(),
|
||||
local_sync_path=resolved_path.as_posix(),
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Sync configured for project '{name}'[/green]")
|
||||
console.print(f"\nLocal sync path: {resolved_path}")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
+1
-8
@@ -4,14 +4,13 @@ import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.app import cloud_app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.mcp.project_context import get_available_workspaces
|
||||
|
||||
console = Console()
|
||||
|
||||
workspace_app = typer.Typer(help="Manage cloud workspaces")
|
||||
app.add_typer(workspace_app, name="workspace")
|
||||
|
||||
|
||||
@workspace_app.command("list")
|
||||
@@ -49,9 +48,3 @@ def list_workspaces() -> None:
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@app.command("workspaces")
|
||||
def workspaces_alias() -> None:
|
||||
"""Alias for `bm workspace list`."""
|
||||
list_workspaces()
|
||||
@@ -12,6 +12,15 @@ from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
|
||||
from basic_memory.cli.commands.cloud.project_sync import (
|
||||
_has_cloud_credentials,
|
||||
_require_cloud_credentials,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
SyncProject,
|
||||
project_ls,
|
||||
)
|
||||
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager, ProjectEntry, ProjectMode
|
||||
@@ -21,17 +30,6 @@ from basic_memory.schemas.project_info import ProjectItem, ProjectList, ProjectS
|
||||
from basic_memory.schemas.v2 import ProjectResolveResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
# Import rclone commands for project sync
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
RcloneError,
|
||||
SyncProject,
|
||||
project_bisync,
|
||||
project_check,
|
||||
project_ls,
|
||||
project_sync,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
|
||||
|
||||
console = Console()
|
||||
|
||||
# Create a project subcommand
|
||||
@@ -47,25 +45,6 @@ def format_path(path: str) -> str:
|
||||
return path
|
||||
|
||||
|
||||
def _has_cloud_credentials(config) -> bool:
|
||||
"""Return whether cloud credentials are available (API key or OAuth token)."""
|
||||
if config.cloud_api_key:
|
||||
return True
|
||||
|
||||
auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain)
|
||||
return auth.load_tokens() is not None
|
||||
|
||||
|
||||
def _require_cloud_credentials(config) -> None:
|
||||
"""Exit with actionable guidance when cloud credentials are missing."""
|
||||
if _has_cloud_credentials(config):
|
||||
return
|
||||
|
||||
console.print("[red]Error: cloud credentials are required for this command[/red]")
|
||||
console.print("[dim]Run 'bm cloud login' or 'bm cloud set-key <key>' first[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("list")
|
||||
def list_projects(
|
||||
local: bool = typer.Option(False, "--local", help="Force local routing for this command"),
|
||||
@@ -141,8 +120,8 @@ def list_projects(
|
||||
local_path = ""
|
||||
if local_project is not None:
|
||||
local_path = format_path(normalize_project_path(local_project.path))
|
||||
elif entry and entry.cloud_sync_path:
|
||||
local_path = format_path(entry.cloud_sync_path)
|
||||
elif entry and entry.local_sync_path:
|
||||
local_path = format_path(entry.local_sync_path)
|
||||
elif entry and entry.mode == ProjectMode.LOCAL and entry.path:
|
||||
local_path = format_path(normalize_project_path(entry.path))
|
||||
|
||||
@@ -169,7 +148,7 @@ def list_projects(
|
||||
if cloud_project is not None and cloud_project.is_default:
|
||||
is_default = "[X]"
|
||||
|
||||
has_sync = "[X]" if entry and entry.cloud_sync_path else ""
|
||||
has_sync = "[X]" if entry and entry.local_sync_path else ""
|
||||
mcp_stdio_target = "local" if local_project is not None else "n/a"
|
||||
|
||||
row = [
|
||||
@@ -188,7 +167,7 @@ def list_projects(
|
||||
if cloud_error is not None:
|
||||
console.print(
|
||||
"[yellow]Cloud project discovery failed. "
|
||||
"Showing local projects only. Run 'bm cloud login' or 'bm cloud set-key <key>'.[/yellow]"
|
||||
"Showing local projects only. Run 'bm cloud login' or 'bm cloud api-key save <key>'.[/yellow]"
|
||||
)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error listing projects: {str(e)}[/red]")
|
||||
@@ -281,80 +260,24 @@ def add_project(
|
||||
# Update project entry with sync path
|
||||
entry = config.projects.get(name)
|
||||
if entry:
|
||||
entry.cloud_sync_path = local_sync_path
|
||||
entry.local_sync_path = local_sync_path
|
||||
else:
|
||||
# Project may not be in local config yet (cloud-only add)
|
||||
config.projects[name] = ProjectEntry(
|
||||
path=local_sync_path,
|
||||
cloud_sync_path=local_sync_path,
|
||||
local_sync_path=local_sync_path,
|
||||
)
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
|
||||
console.print(f" 1. Preview: bm cloud bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm cloud bisync --name {name} --resync")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error adding project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("sync-setup")
|
||||
def setup_project_sync(
|
||||
name: str = typer.Argument(..., help="Project name"),
|
||||
local_path: str = typer.Argument(..., help="Local sync directory"),
|
||||
) -> None:
|
||||
"""Configure local sync for an existing cloud project.
|
||||
|
||||
Example:
|
||||
bm project sync-setup research ~/Documents/research
|
||||
"""
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
async def _verify_project_exists():
|
||||
"""Verify the project exists on cloud by listing all projects."""
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
project_list = response.json()
|
||||
project_names = [p["name"] for p in project_list["projects"]]
|
||||
if name not in project_names:
|
||||
raise ValueError(f"Project '{name}' not found on cloud")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Verify project exists on cloud
|
||||
with force_routing(cloud=True):
|
||||
run_with_cleanup(_verify_project_exists())
|
||||
|
||||
# Resolve and create local path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
|
||||
resolved_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Update project entry with sync path
|
||||
entry = config.projects.get(name)
|
||||
if entry:
|
||||
entry.cloud_sync_path = resolved_path.as_posix()
|
||||
entry.bisync_initialized = False
|
||||
entry.last_sync = None
|
||||
else:
|
||||
config.projects[name] = ProjectEntry(
|
||||
path=resolved_path.as_posix(),
|
||||
cloud_sync_path=resolved_path.as_posix(),
|
||||
)
|
||||
config_manager.save_config(config)
|
||||
|
||||
console.print(f"[green]Sync configured for project '{name}'[/green]")
|
||||
console.print(f"\nLocal sync path: {resolved_path}")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("remove")
|
||||
def remove_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to remove"),
|
||||
@@ -400,8 +323,8 @@ def remove_project(
|
||||
has_bisync_state = False
|
||||
|
||||
entry = config.projects.get(name)
|
||||
if cloud and entry and entry.cloud_sync_path:
|
||||
local_path_config = entry.cloud_sync_path
|
||||
if cloud and entry and entry.local_sync_path:
|
||||
local_path_config = entry.local_sync_path
|
||||
|
||||
# Check for bisync state
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
|
||||
@@ -434,8 +357,8 @@ def remove_project(
|
||||
console.print("[green]Removed bisync state[/green]")
|
||||
|
||||
# Clean up cloud sync fields on the project entry
|
||||
if cloud and entry and entry.cloud_sync_path:
|
||||
entry.cloud_sync_path = None
|
||||
if cloud and entry and entry.local_sync_path:
|
||||
entry.local_sync_path = None
|
||||
entry.bisync_initialized = False
|
||||
entry.last_sync = None
|
||||
ConfigManager().save_config(config)
|
||||
@@ -516,13 +439,11 @@ def synchronize_projects(
|
||||
def move_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to move"),
|
||||
new_path: str = typer.Argument(..., help="New absolute path for the project"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (required in cloud mode)"
|
||||
),
|
||||
) -> None:
|
||||
"""Move a project to a new location.
|
||||
"""Move a local project to a new filesystem location.
|
||||
|
||||
In cloud mode, use --local to modify local project paths.
|
||||
This command only applies to local projects — it updates the project's
|
||||
configured path in the local database.
|
||||
"""
|
||||
# Resolve to absolute path
|
||||
resolved_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
|
||||
@@ -542,7 +463,7 @@ def move_project(
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
with force_routing(local=local):
|
||||
with force_routing(local=True):
|
||||
result = run_with_cleanup(_move_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
@@ -574,13 +495,12 @@ def set_cloud(
|
||||
Requires either an API key or an active OAuth session.
|
||||
|
||||
Examples:
|
||||
bm cloud set-key bmc_abc123... # save API key, then:
|
||||
bm cloud api-key save bmc_abc123... # save API key, then:
|
||||
bm project set-cloud research # route "research" through cloud
|
||||
|
||||
bm cloud login # OAuth login, then:
|
||||
bm project set-cloud research # route "research" through cloud
|
||||
"""
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
config_manager = ConfigManager()
|
||||
config = config_manager.config
|
||||
@@ -599,7 +519,7 @@ def set_cloud(
|
||||
|
||||
if not has_api_key and not has_oauth:
|
||||
console.print("[red]Error: No cloud credentials found[/red]")
|
||||
console.print("[dim]Run 'bm cloud set-key <key>' or 'bm cloud login' first[/dim]")
|
||||
console.print("[dim]Run 'bm cloud api-key save <key>' or 'bm cloud login' first[/dim]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config.set_project_mode(name, ProjectMode.CLOUD)
|
||||
@@ -633,293 +553,6 @@ def set_local(
|
||||
console.print("[dim]MCP tools and CLI commands for this project will use local transport[/dim]")
|
||||
|
||||
|
||||
@project_app.command("sync")
|
||||
def sync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to sync"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""One-way sync: local -> cloud (make cloud identical to local).
|
||||
|
||||
Example:
|
||||
bm project sync --name research
|
||||
bm project sync --name research --dry-run
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
return proj
|
||||
return None
|
||||
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get local_sync_path from project entry
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Create SyncProject
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
local_sync_path=local_sync_path,
|
||||
)
|
||||
|
||||
# Run sync
|
||||
console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
|
||||
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{name} synced successfully[/green]")
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
if not dry_run:
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client() as client:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
|
||||
json={},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]{name} sync failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Sync error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("bisync")
|
||||
def bisync_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to bisync"),
|
||||
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
|
||||
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
|
||||
) -> None:
|
||||
"""Two-way sync: local <-> cloud (bidirectional sync).
|
||||
|
||||
Examples:
|
||||
bm project bisync --name research --resync # First time
|
||||
bm project bisync --name research # Subsequent syncs
|
||||
bm project bisync --name research --dry-run # Preview changes
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
return proj
|
||||
return None
|
||||
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get local_sync_path from project entry
|
||||
sync_entry = config.projects.get(name)
|
||||
local_sync_path = sync_entry.cloud_sync_path if sync_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Create SyncProject
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
local_sync_path=local_sync_path,
|
||||
)
|
||||
|
||||
# Run bisync
|
||||
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
|
||||
success = project_bisync(
|
||||
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
|
||||
)
|
||||
|
||||
if success:
|
||||
console.print(f"[green]{name} bisync completed successfully[/green]")
|
||||
|
||||
# Update config — sync_entry is guaranteed non-None because
|
||||
# we checked local_sync_path above (which comes from sync_entry)
|
||||
assert sync_entry is not None
|
||||
sync_entry.last_sync = datetime.now()
|
||||
sync_entry.bisync_initialized = True
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Trigger database sync if not a dry run
|
||||
if not dry_run:
|
||||
|
||||
async def _trigger_db_sync():
|
||||
async with get_client() as client:
|
||||
response = await call_post(
|
||||
client,
|
||||
f"/v2/projects/{project_data.external_id}/sync?force_full=true",
|
||||
json={},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
with force_routing(cloud=True):
|
||||
result = run_with_cleanup(_trigger_db_sync())
|
||||
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]{name} bisync failed[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Bisync error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("check")
|
||||
def check_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to check"),
|
||||
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
|
||||
) -> None:
|
||||
"""Verify file integrity between local and cloud.
|
||||
|
||||
Example:
|
||||
bm project check --name research
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
_require_cloud_credentials(config)
|
||||
|
||||
try:
|
||||
# Get tenant info for bucket name
|
||||
tenant_info = run_with_cleanup(get_mount_info())
|
||||
bucket_name = tenant_info.bucket_name
|
||||
|
||||
# Get project info
|
||||
async def _get_project():
|
||||
async with get_client() as client:
|
||||
response = await call_get(client, "/v2/projects/")
|
||||
projects_list = ProjectList.model_validate(response.json())
|
||||
for proj in projects_list.projects:
|
||||
if generate_permalink(proj.name) == generate_permalink(name):
|
||||
return proj
|
||||
return None
|
||||
|
||||
with force_routing(cloud=True):
|
||||
project_data = run_with_cleanup(_get_project())
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Get local_sync_path from project entry
|
||||
check_entry = config.projects.get(name)
|
||||
local_sync_path = check_entry.cloud_sync_path if check_entry else None
|
||||
|
||||
if not local_sync_path:
|
||||
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
|
||||
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Create SyncProject
|
||||
sync_project = SyncProject(
|
||||
name=project_data.name,
|
||||
path=normalize_project_path(project_data.path),
|
||||
local_sync_path=local_sync_path,
|
||||
)
|
||||
|
||||
# Run check
|
||||
console.print(f"[blue]Checking {name} integrity...[/blue]")
|
||||
match = project_check(sync_project, bucket_name, one_way=one_way)
|
||||
|
||||
if match:
|
||||
console.print(f"[green]{name} files match[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]!{name} has differences[/yellow]")
|
||||
|
||||
except RcloneError as e:
|
||||
console.print(f"[red]Check error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("bisync-reset")
|
||||
def bisync_reset(
|
||||
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
|
||||
) -> None:
|
||||
"""Clear bisync state for a project.
|
||||
|
||||
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
|
||||
Useful when bisync gets into an inconsistent state or when remote path changes.
|
||||
"""
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
|
||||
import shutil
|
||||
|
||||
try:
|
||||
state_path = get_project_bisync_state(name)
|
||||
|
||||
if not state_path.exists():
|
||||
console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
|
||||
return
|
||||
|
||||
# Remove the entire state directory
|
||||
shutil.rmtree(state_path)
|
||||
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
|
||||
console.print("\nNext steps:")
|
||||
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
|
||||
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@project_app.command("ls")
|
||||
def ls_project_command(
|
||||
name: str = typer.Option(..., "--name", help="Project name to list files from"),
|
||||
@@ -941,7 +574,13 @@ def ls_project_command(
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
use_cloud_route = cloud and not local
|
||||
# Determine routing: explicit flags take precedence, otherwise check project mode
|
||||
if cloud or local:
|
||||
use_cloud_route = cloud and not local
|
||||
else:
|
||||
config = ConfigManager().config
|
||||
project_mode = config.get_project_mode(name)
|
||||
use_cloud_route = project_mode == ProjectMode.CLOUD
|
||||
|
||||
def _list_local_files(project_path: str, subpath: str | None = None) -> list[str]:
|
||||
project_root = Path(normalize_project_path(project_path)).expanduser().resolve()
|
||||
@@ -1005,7 +644,16 @@ def ls_project_command(
|
||||
if not project_data:
|
||||
console.print(f"[red]Error: Project '{name}' not found[/red]")
|
||||
raise typer.Exit(1)
|
||||
files = _list_local_files(project_data.path, path)
|
||||
|
||||
# For cloud-mode projects accessed with --local, use local_sync_path
|
||||
# (the actual local directory) instead of project_data.path from the API
|
||||
local_dir = project_data.path
|
||||
if local:
|
||||
entry = ConfigManager().config.projects.get(name)
|
||||
if entry and entry.local_sync_path:
|
||||
local_dir = entry.local_sync_path
|
||||
|
||||
files = _list_local_files(local_dir, path)
|
||||
target_label = "LOCAL"
|
||||
|
||||
if files:
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Provides CLI access to schema validation, inference, and drift detection.
|
||||
Registered as a subcommand group: `bm schema validate`, `bm schema infer`, `bm schema diff`.
|
||||
|
||||
Each command calls the corresponding MCP tool with output_format="json" and
|
||||
renders the result as Rich tables — same code path as `bm tool schema-*` but
|
||||
with human-friendly formatting.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -16,8 +20,9 @@ from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.project_context import get_active_project
|
||||
from basic_memory.mcp.tools import schema_diff as mcp_schema_diff
|
||||
from basic_memory.mcp.tools import schema_infer as mcp_schema_infer
|
||||
from basic_memory.mcp.tools import schema_validate as mcp_schema_validate
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -37,77 +42,124 @@ def _resolve_project_name(project: Optional[str]) -> Optional[str]:
|
||||
return config_manager.default_project
|
||||
|
||||
|
||||
# --- Validate ---
|
||||
# --- Rendering helpers ---
|
||||
|
||||
|
||||
async def _run_validate(
|
||||
target: Optional[str] = None,
|
||||
project: Optional[str] = None,
|
||||
strict: bool = False,
|
||||
):
|
||||
"""Run schema validation via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
def _render_validate_table(data: dict) -> None:
|
||||
"""Render a validation report dict as a Rich table."""
|
||||
entity_type = data.get("entity_type")
|
||||
title_label = entity_type or "all"
|
||||
|
||||
async with get_client(project_name=project) as client:
|
||||
active_project = await get_active_project(client, project, None)
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
table = Table(title=f"Schema Validation: {title_label}")
|
||||
table.add_column("Note", style="cyan")
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Warnings", justify="right")
|
||||
table.add_column("Errors", justify="right")
|
||||
|
||||
# Determine if target is a note identifier or note type
|
||||
# Heuristic: if target contains / or ., treat as identifier
|
||||
entity_type = None
|
||||
identifier = None
|
||||
if target:
|
||||
if "/" in target or "." in target:
|
||||
identifier = target
|
||||
else:
|
||||
entity_type = target
|
||||
for result in data.get("results", []):
|
||||
warnings = result.get("warnings", [])
|
||||
errors = result.get("errors", [])
|
||||
passed = result.get("passed", True)
|
||||
|
||||
report = await schema_client.validate(
|
||||
entity_type=entity_type,
|
||||
identifier=identifier,
|
||||
if passed and not warnings:
|
||||
status = "[green]pass[/green]"
|
||||
elif passed:
|
||||
status = "[yellow]warn[/yellow]"
|
||||
else:
|
||||
status = "[red]fail[/red]"
|
||||
|
||||
table.add_row(
|
||||
result.get("note_identifier", ""),
|
||||
status,
|
||||
str(len(warnings)),
|
||||
str(len(errors)),
|
||||
)
|
||||
|
||||
# --- Display results ---
|
||||
if report.total_notes == 0:
|
||||
if report.total_entities == 0:
|
||||
console.print(f"[yellow]No notes of type '{entity_type}' found.[/yellow]")
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]Found {report.total_entities} notes but no schema "
|
||||
f"defined for '{entity_type}'.[/yellow]"
|
||||
)
|
||||
return
|
||||
console.print(table)
|
||||
console.print(
|
||||
f"\nSummary: {data.get('valid_count', 0)}/{data.get('total_notes', 0)} valid, "
|
||||
f"{data.get('warning_count', 0)} warnings, {data.get('error_count', 0)} errors"
|
||||
)
|
||||
|
||||
table = Table(title=f"Schema Validation: {entity_type or identifier or 'all'}")
|
||||
table.add_column("Note", style="cyan")
|
||||
table.add_column("Status", justify="center")
|
||||
table.add_column("Warnings", justify="right")
|
||||
table.add_column("Errors", justify="right")
|
||||
|
||||
for result in report.results:
|
||||
if result.passed and not result.warnings:
|
||||
status = "[green]pass[/green]"
|
||||
elif result.passed:
|
||||
status = "[yellow]warn[/yellow]"
|
||||
else:
|
||||
status = "[red]fail[/red]"
|
||||
def _render_infer_table(data: dict) -> None:
|
||||
"""Render an inference report dict as a Rich table."""
|
||||
entity_type = data.get("entity_type", "")
|
||||
notes_analyzed = data.get("notes_analyzed", 0)
|
||||
suggested_required = data.get("suggested_required", [])
|
||||
suggested_optional = data.get("suggested_optional", [])
|
||||
|
||||
table.add_row(
|
||||
result.note_identifier,
|
||||
status,
|
||||
str(len(result.warnings)),
|
||||
str(len(result.errors)),
|
||||
console.print(f"\n[bold]Analyzing {notes_analyzed} notes with type: {entity_type}...[/bold]\n")
|
||||
|
||||
table = Table(title="Field Frequencies")
|
||||
table.add_column("Field", style="cyan")
|
||||
table.add_column("Source")
|
||||
table.add_column("Count", justify="right")
|
||||
table.add_column("Percentage", justify="right")
|
||||
table.add_column("Suggested")
|
||||
|
||||
for freq in data.get("field_frequencies", []):
|
||||
pct = f"{freq.get('percentage', 0):.0%}"
|
||||
name = freq.get("name", "")
|
||||
if name in suggested_required:
|
||||
suggested = "[green]required[/green]"
|
||||
elif name in suggested_optional:
|
||||
suggested = "[yellow]optional[/yellow]"
|
||||
else:
|
||||
suggested = "[dim]excluded[/dim]"
|
||||
|
||||
table.add_row(
|
||||
name,
|
||||
freq.get("source", ""),
|
||||
str(freq.get("count", 0)),
|
||||
pct,
|
||||
suggested,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
suggested_schema = data.get("suggested_schema", {})
|
||||
if suggested_schema:
|
||||
console.print("\n[bold]Suggested schema:[/bold]")
|
||||
console.print(json.dumps(suggested_schema, indent=2))
|
||||
|
||||
|
||||
def _render_diff_output(data: dict) -> None:
|
||||
"""Render a drift report dict as Rich output."""
|
||||
entity_type = data.get("entity_type", "")
|
||||
new_fields = data.get("new_fields", [])
|
||||
dropped_fields = data.get("dropped_fields", [])
|
||||
cardinality_changes = data.get("cardinality_changes", [])
|
||||
|
||||
has_drift = new_fields or dropped_fields or cardinality_changes
|
||||
|
||||
if not has_drift:
|
||||
console.print(f"[green]No drift detected for {entity_type} schema.[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Schema drift detected for {entity_type}:[/bold]\n")
|
||||
|
||||
if new_fields:
|
||||
console.print("[green]+ New fields (common in notes, not in schema):[/green]")
|
||||
for f in new_fields:
|
||||
console.print(
|
||||
f" + {f['name']}: {f.get('percentage', 0):.0%} of notes ({f.get('source', '')})"
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(
|
||||
f"\nSummary: {report.valid_count}/{report.total_notes} valid, "
|
||||
f"{report.warning_count} warnings, {report.error_count} errors"
|
||||
)
|
||||
if dropped_fields:
|
||||
console.print("[red]- Dropped fields (in schema, rare in notes):[/red]")
|
||||
for f in dropped_fields:
|
||||
console.print(
|
||||
f" - {f['name']}: {f.get('percentage', 0):.0%} of notes ({f.get('source', '')})"
|
||||
)
|
||||
|
||||
# Exit with error code in strict mode if there are failures
|
||||
if strict and report.error_count > 0:
|
||||
raise typer.Exit(1)
|
||||
if cardinality_changes:
|
||||
console.print("[yellow]~ Cardinality changes:[/yellow]")
|
||||
for change in cardinality_changes:
|
||||
console.print(f" ~ {change}")
|
||||
|
||||
|
||||
# --- Commands ---
|
||||
|
||||
|
||||
@schema_app.command()
|
||||
@@ -138,8 +190,36 @@ def validate(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
project_name = _resolve_project_name(project)
|
||||
|
||||
# Heuristic: if target contains / or ., treat as identifier; otherwise as note type
|
||||
note_type, identifier = None, None
|
||||
if target:
|
||||
if "/" in target or "." in target:
|
||||
identifier = target
|
||||
else:
|
||||
note_type = target
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(_run_validate(target, project_name, strict))
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_validate(
|
||||
note_type=note_type,
|
||||
identifier=identifier,
|
||||
project=project_name,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
_render_validate_table(result)
|
||||
|
||||
if strict and result.get("error_count", 0) > 0:
|
||||
raise typer.Exit(1)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -151,91 +231,6 @@ def validate(
|
||||
raise
|
||||
|
||||
|
||||
# --- Infer ---
|
||||
|
||||
|
||||
async def _run_infer(
|
||||
entity_type: str,
|
||||
project: Optional[str] = None,
|
||||
threshold: float = 0.25,
|
||||
save: bool = False,
|
||||
):
|
||||
"""Run schema inference via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
async with get_client(project_name=project) as client:
|
||||
active_project = await get_active_project(client, project, None)
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
|
||||
report = await schema_client.infer(entity_type, threshold=threshold)
|
||||
|
||||
if report.notes_analyzed == 0:
|
||||
console.print(f"[yellow]No notes found with type: {entity_type}[/yellow]")
|
||||
return
|
||||
|
||||
# --- Empty schema guard ---
|
||||
# Trigger: notes were analyzed but no fields met the threshold
|
||||
# Why: dumping hundreds of excluded fields is not useful output
|
||||
# Outcome: show count and suggest a more specific type
|
||||
if not report.suggested_schema:
|
||||
console.print(
|
||||
f"\n[yellow]Analyzed {report.notes_analyzed} notes of type '{entity_type}', "
|
||||
f"but no fields met the {threshold:.0%} threshold.[/yellow]\n"
|
||||
)
|
||||
console.print(
|
||||
f"This usually means '{entity_type}' is too broad — "
|
||||
f"the notes don't share a consistent structure.\n"
|
||||
)
|
||||
console.print("[bold]Suggestions:[/bold]")
|
||||
console.print(" 1. Use a more specific type")
|
||||
console.print(
|
||||
f" 2. Lower the threshold: bm schema infer {entity_type} --threshold 0.1"
|
||||
)
|
||||
console.print(" 3. Create typed notes with write_note using a specific note_type")
|
||||
return
|
||||
|
||||
# --- Display frequency analysis ---
|
||||
console.print(
|
||||
f"\n[bold]Analyzing {report.notes_analyzed} notes with type: {entity_type}...[/bold]\n"
|
||||
)
|
||||
|
||||
table = Table(title="Field Frequencies")
|
||||
table.add_column("Field", style="cyan")
|
||||
table.add_column("Source")
|
||||
table.add_column("Count", justify="right")
|
||||
table.add_column("Percentage", justify="right")
|
||||
table.add_column("Suggested")
|
||||
|
||||
for freq in report.field_frequencies:
|
||||
pct = f"{freq.percentage:.0%}"
|
||||
if freq.name in report.suggested_required:
|
||||
suggested = "[green]required[/green]"
|
||||
elif freq.name in report.suggested_optional:
|
||||
suggested = "[yellow]optional[/yellow]"
|
||||
else:
|
||||
suggested = "[dim]excluded[/dim]"
|
||||
|
||||
table.add_row(
|
||||
freq.name,
|
||||
freq.source,
|
||||
str(freq.count),
|
||||
pct,
|
||||
suggested,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
# --- Display suggested schema ---
|
||||
console.print("\n[bold]Suggested schema:[/bold]")
|
||||
console.print(json.dumps(report.suggested_schema, indent=2))
|
||||
|
||||
if save:
|
||||
console.print(
|
||||
f"\n[yellow]--save not yet implemented. "
|
||||
f"Copy the schema above into schema/{entity_type}.md[/yellow]"
|
||||
)
|
||||
|
||||
|
||||
@schema_app.command()
|
||||
def infer(
|
||||
entity_type: Annotated[
|
||||
@@ -269,8 +264,37 @@ def infer(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
project_name = _resolve_project_name(project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(_run_infer(entity_type, project_name, threshold, save))
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_infer(
|
||||
note_type=entity_type,
|
||||
threshold=threshold,
|
||||
project=project_name,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
|
||||
# Handle zero notes
|
||||
if result.get("notes_analyzed", 0) == 0:
|
||||
console.print(f"[yellow]No notes found with type: {entity_type}[/yellow]")
|
||||
return
|
||||
|
||||
_render_infer_table(result)
|
||||
|
||||
if save:
|
||||
console.print(
|
||||
f"\n[yellow]--save not yet implemented. "
|
||||
f"Copy the schema above into schema/{entity_type}.md[/yellow]"
|
||||
)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
@@ -282,46 +306,6 @@ def infer(
|
||||
raise
|
||||
|
||||
|
||||
# --- Diff ---
|
||||
|
||||
|
||||
async def _run_diff(
|
||||
entity_type: str,
|
||||
project: Optional[str] = None,
|
||||
):
|
||||
"""Run schema drift detection via the API."""
|
||||
from basic_memory.mcp.clients.schema import SchemaClient
|
||||
|
||||
async with get_client(project_name=project) as client:
|
||||
active_project = await get_active_project(client, project, None)
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
|
||||
report = await schema_client.diff(entity_type)
|
||||
|
||||
has_drift = report.new_fields or report.dropped_fields or report.cardinality_changes
|
||||
|
||||
if not has_drift:
|
||||
console.print(f"[green]No drift detected for {entity_type} schema.[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Schema drift detected for {entity_type}:[/bold]\n")
|
||||
|
||||
if report.new_fields:
|
||||
console.print("[green]+ New fields (common in notes, not in schema):[/green]")
|
||||
for f in report.new_fields:
|
||||
console.print(f" + {f.name}: {f.percentage:.0%} of notes ({f.source})")
|
||||
|
||||
if report.dropped_fields:
|
||||
console.print("[red]- Dropped fields (in schema, rare in notes):[/red]")
|
||||
for f in report.dropped_fields:
|
||||
console.print(f" - {f.name}: {f.percentage:.0%} of notes ({f.source})")
|
||||
|
||||
if report.cardinality_changes:
|
||||
console.print("[yellow]~ Cardinality changes:[/yellow]")
|
||||
for change in report.cardinality_changes:
|
||||
console.print(f" ~ {change}")
|
||||
|
||||
|
||||
@schema_app.command()
|
||||
def diff(
|
||||
entity_type: Annotated[
|
||||
@@ -349,8 +333,24 @@ def diff(
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
project_name = _resolve_project_name(project)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(_run_diff(entity_type, project_name))
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_diff(
|
||||
note_type=entity_type,
|
||||
project=project_name,
|
||||
output_format="json",
|
||||
)
|
||||
)
|
||||
|
||||
# Handle error responses
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
console.print(f"[yellow]{result['error']}[/yellow]")
|
||||
return
|
||||
|
||||
# output_format="json" guarantees a dict return
|
||||
assert isinstance(result, dict)
|
||||
_render_diff_output(result)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
"""Watch command - run file watcher as a standalone long-running process."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory import db
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.container import get_container
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.services.initialization import initialize_app
|
||||
from basic_memory.sync.coordinator import SyncCoordinator
|
||||
|
||||
|
||||
async def run_watch(project: Optional[str] = None) -> None:
|
||||
"""Run the file watcher as a long-running process.
|
||||
|
||||
This is the async core of the watch command. It:
|
||||
1. Initializes the app (DB migrations + project reconciliation)
|
||||
2. Validates and sets project constraint if --project given
|
||||
3. Creates a SyncCoordinator with quiet=False for Rich console output
|
||||
4. Blocks until SIGINT/SIGTERM, then shuts down cleanly
|
||||
"""
|
||||
container = get_container()
|
||||
config = container.config
|
||||
|
||||
# --- Initialization ---
|
||||
# Wrapped in try/finally so DB resources are cleaned up on all exit paths,
|
||||
# including early exits from invalid --project names.
|
||||
await initialize_app(config)
|
||||
sync_coordinator = None
|
||||
|
||||
try:
|
||||
# --- Project constraint ---
|
||||
if project:
|
||||
config_manager = ConfigManager()
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
|
||||
logger.info(f"Watch constrained to project: {project_name}")
|
||||
|
||||
# --- Sync coordinator ---
|
||||
# quiet=False so file change events are printed to the terminal
|
||||
sync_coordinator = SyncCoordinator(config=config, should_sync=True, quiet=False)
|
||||
|
||||
# --- Signal handling ---
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def _signal_handler() -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
shutdown_event.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Windows ProactorEventLoop does not support add_signal_handler;
|
||||
# fall back to the stdlib signal module which works cross-platform.
|
||||
try:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
except NotImplementedError:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(sig, lambda _signum, _frame: _signal_handler())
|
||||
|
||||
# --- Run ---
|
||||
await sync_coordinator.start()
|
||||
logger.info("Watch service running, press Ctrl+C to stop")
|
||||
await shutdown_event.wait()
|
||||
finally:
|
||||
if sync_coordinator is not None:
|
||||
await sync_coordinator.stop()
|
||||
await db.shutdown_db()
|
||||
logger.info("Watch service stopped")
|
||||
|
||||
|
||||
@app.command()
|
||||
def watch(
|
||||
project: Optional[str] = typer.Option(None, help="Restrict watcher to a single project"),
|
||||
) -> None:
|
||||
"""Run file watcher as a long-running process (no MCP server).
|
||||
|
||||
Watches for file changes in project directories and syncs them to the
|
||||
database. Useful for running Basic Memory sync alongside external tools
|
||||
that don't use the MCP server.
|
||||
"""
|
||||
# On Windows, use SelectorEventLoop to avoid ProactorEventLoop cleanup issues
|
||||
if sys.platform == "win32": # pragma: no cover
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
asyncio.run(run_watch(project=project))
|
||||
@@ -28,7 +28,6 @@ if not _version_only_invocation(sys.argv[1:]):
|
||||
schema,
|
||||
status,
|
||||
tool,
|
||||
workspace,
|
||||
)
|
||||
|
||||
warnings.filterwarnings("ignore") # pragma: no cover
|
||||
|
||||
Reference in New Issue
Block a user