mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat: add basic-memory watch CLI command (#559)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,17 @@ def app_callback(
|
||||
# Skip for 'mcp' command - it has its own lifespan that handles initialization
|
||||
# Skip for API-using commands (status, sync, etc.) - they handle initialization via deps.py
|
||||
# Skip for 'reset' command - it manages its own database lifecycle
|
||||
skip_init_commands = {"doctor", "mcp", "status", "sync", "project", "tool", "reset", "reindex"}
|
||||
skip_init_commands = {
|
||||
"doctor",
|
||||
"mcp",
|
||||
"status",
|
||||
"sync",
|
||||
"project",
|
||||
"tool",
|
||||
"reset",
|
||||
"reindex",
|
||||
"watch",
|
||||
}
|
||||
if (
|
||||
not version
|
||||
and ctx.invoked_subcommand is not None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""CLI commands for basic-memory."""
|
||||
|
||||
from . import status, db, doctor, import_memory_json, mcp, import_claude_conversations
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format, schema
|
||||
from . import import_claude_projects, import_chatgpt, tool, project, format, schema, watch
|
||||
|
||||
__all__ = [
|
||||
"status",
|
||||
@@ -16,4 +16,5 @@ __all__ = [
|
||||
"project",
|
||||
"format",
|
||||
"schema",
|
||||
"watch",
|
||||
]
|
||||
|
||||
@@ -111,9 +111,7 @@ def reindex(
|
||||
embeddings: bool = typer.Option(
|
||||
False, "--embeddings", "-e", help="Rebuild vector embeddings (requires semantic search)"
|
||||
),
|
||||
search: bool = typer.Option(
|
||||
False, "--search", "-s", help="Rebuild full-text search index"
|
||||
),
|
||||
search: bool = typer.Option(False, "--search", "-s", help="Rebuild full-text search index"),
|
||||
project: str = typer.Option(
|
||||
None, "--project", "-p", help="Reindex a specific project (default: all)"
|
||||
),
|
||||
@@ -193,12 +191,8 @@ async def _reindex(app_config, search: bool, embeddings: bool, project: str | No
|
||||
project_path = Path(proj.path)
|
||||
entity_parser = EntityParser(project_path)
|
||||
markdown_processor = MarkdownProcessor(entity_parser, app_config=app_config)
|
||||
file_service = FileService(
|
||||
project_path, markdown_processor, app_config=app_config
|
||||
)
|
||||
search_service = SearchService(
|
||||
search_repository, entity_repository, file_service
|
||||
)
|
||||
file_service = FileService(project_path, markdown_processor, app_config=app_config)
|
||||
search_service = SearchService(search_repository, entity_repository, file_service)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
@@ -212,9 +206,7 @@ async def _reindex(app_config, search: bool, embeddings: bool, project: str | No
|
||||
def on_progress(entity_id, index, total):
|
||||
progress.update(task, total=total, completed=index)
|
||||
|
||||
stats = await search_service.reindex_vectors(
|
||||
progress_callback=on_progress
|
||||
)
|
||||
stats = await search_service.reindex_vectors(progress_callback=on_progress)
|
||||
progress.update(task, completed=stats["total_entities"])
|
||||
|
||||
console.print(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user