Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ dependencies = [
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
"pytest-asyncio>=1.2.0",
"psycopg==3.3.1",
"mdformat>=0.7.22",
"mdformat-gfm>=0.3.7",
"mdformat-frontmatter>=2.0.8",
]


Expand Down
4 changes: 1 addition & 3 deletions src/basic_memory/api/v2/routers/knowledge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,7 @@ async def resolve_identifier(
# Try to resolve the identifier
entity = await link_resolver.resolve_link(data.identifier)
if not entity:
raise HTTPException(
status_code=404, detail=f"Entity not found: '{data.identifier}'"
)
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")

# Determine resolution method
resolution_method = "search" # default
Expand Down
4 changes: 1 addition & 3 deletions src/basic_memory/api/v2/routers/project_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ async def resolve_project_identifier(
resolution_method = "name"

if not project:
raise HTTPException(
status_code=404, detail=f"Project not found: '{data.identifier}'"
)
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")

return ProjectResolveResponse(
project_id=project.id,
Expand Down
3 changes: 2 additions & 1 deletion src/basic_memory/cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""CLI commands for basic-memory."""

from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project
from . import import_claude_projects, import_chatgpt, tool, project, format

__all__ = [
"status",
Expand All @@ -13,4 +13,5 @@
"import_chatgpt",
"tool",
"project",
"format",
]
198 changes: 198 additions & 0 deletions src/basic_memory/cli/commands/format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
"""Format command for basic-memory CLI."""

import asyncio
from pathlib import Path
from typing import Annotated, Optional

import typer
from loguru import logger
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn

from basic_memory.cli.app import app
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.file_utils import format_file

console = Console()


def is_markdown_extension(path: Path) -> bool:
"""Check if file has a markdown extension."""
return path.suffix.lower() in (".md", ".markdown")


async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]:
"""Format a single file.

Returns:
Tuple of (path, success, error_message)
"""
try:
result = await format_file(
file_path, app_config, is_markdown=is_markdown_extension(file_path)
)
if result is not None:
return (file_path, True, None)
else:
return (file_path, False, "No formatter configured or formatting skipped")
except Exception as e:
return (file_path, False, str(e))


async def format_files(
paths: list[Path], app_config, show_progress: bool = True
) -> tuple[int, int, list[tuple[Path, str]]]:
"""Format multiple files.

Returns:
Tuple of (formatted_count, skipped_count, errors)
"""
formatted = 0
skipped = 0
errors: list[tuple[Path, str]] = []

if show_progress:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task("Formatting files...", total=len(paths))

for file_path in paths:
path, success, error = await format_single_file(file_path, app_config)
if success:
formatted += 1
elif error and "No formatter configured" not in error:
errors.append((path, error))
else:
skipped += 1
progress.update(task, advance=1)
else:
for file_path in paths:
path, success, error = await format_single_file(file_path, app_config)
if success:
formatted += 1
elif error and "No formatter configured" not in error:
errors.append((path, error))
else:
skipped += 1

return formatted, skipped, errors


async def run_format(
path: Optional[Path] = None,
project: Optional[str] = None,
) -> None:
"""Run the format command."""
app_config = ConfigManager().config

# Check if formatting is enabled
if (
not app_config.format_on_save
and not app_config.formatter_command
and not app_config.formatters
):
console.print(
"[yellow]No formatters configured. Set format_on_save=true and "
"formatter_command or formatters in your config.[/yellow]"
)
console.print(
"\nExample config (~/.basic-memory/config.json):\n"
' "format_on_save": true,\n'
' "formatter_command": "prettier --write {file}"\n'
)
raise typer.Exit(1)

# Temporarily enable format_on_save for this command
# (so format_file actually runs the formatter)
original_format_on_save = app_config.format_on_save
app_config.format_on_save = True

try:
# Determine which files to format
if path:
# Format specific file or directory
if path.is_file():
files = [path]
elif path.is_dir():
# Find all markdown and json files
files = (
list(path.rglob("*.md"))
+ list(path.rglob("*.json"))
+ list(path.rglob("*.canvas"))
)
else:
console.print(f"[red]Path not found: {path}[/red]")
raise typer.Exit(1)
else:
# Format all files in project
project_config = get_project_config(project)
project_path = Path(project_config.home)

if not project_path.exists():
console.print(f"[red]Project path not found: {project_path}[/red]")
raise typer.Exit(1)

# Find all markdown and json files
files = (
list(project_path.rglob("*.md"))
+ list(project_path.rglob("*.json"))
+ list(project_path.rglob("*.canvas"))
)

if not files:
console.print("[yellow]No files found to format.[/yellow]")
return

console.print(f"Found {len(files)} file(s) to format...")

formatted, skipped, errors = await format_files(files, app_config)

# Print summary
console.print()
if formatted > 0:
console.print(f"[green]Formatted: {formatted} file(s)[/green]")
if skipped > 0:
console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]")
if errors:
console.print(f"[red]Errors: {len(errors)} file(s)[/red]")
for path, error in errors:
console.print(f" [red]{path}[/red]: {error}")

finally:
# Restore original setting
app_config.format_on_save = original_format_on_save


@app.command()
def format(
path: Annotated[
Optional[Path],
typer.Argument(help="File or directory to format. Defaults to current project."),
] = None,
project: Annotated[
Optional[str],
typer.Option("--project", "-p", help="Project name to format."),
] = None,
) -> None:
"""Format files using configured formatters.

Uses the formatter_command or formatters settings from your config.
By default, formats all .md, .json, and .canvas files in the current project.

Examples:
basic-memory format # Format all files in current project
basic-memory format --project research # Format files in specific project
basic-memory format notes/meeting.md # Format a specific file
basic-memory format notes/ # Format all files in directory
"""
try:
asyncio.run(run_format(path, project))
except Exception as e:
if not isinstance(e, typer.Exit):
logger.error(f"Error formatting files: {e}")
console.print(f"[red]Error formatting files: {e}[/red]")
raise typer.Exit(code=1)
raise
5 changes: 3 additions & 2 deletions src/basic_memory/cli/commands/import_chatgpt.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import typer
from basic_memory.cli.app import import_app
from basic_memory.config import get_project_config
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers import ChatGPTImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
Expand All @@ -20,8 +20,9 @@
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
return MarkdownProcessor(entity_parser, app_config=app_config)


@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")
Expand Down
5 changes: 3 additions & 2 deletions src/basic_memory/cli/commands/import_claude_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import typer
from basic_memory.cli.app import claude_app
from basic_memory.config import get_project_config
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
Expand All @@ -20,8 +20,9 @@
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
return MarkdownProcessor(entity_parser, app_config=app_config)


@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")
Expand Down
5 changes: 3 additions & 2 deletions src/basic_memory/cli/commands/import_claude_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import typer
from basic_memory.cli.app import claude_app
from basic_memory.config import get_project_config
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
Expand All @@ -20,8 +20,9 @@
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
return MarkdownProcessor(entity_parser, app_config=app_config)


@claude_app.command(name="projects", help="Import projects from Claude.ai.")
Expand Down
5 changes: 3 additions & 2 deletions src/basic_memory/cli/commands/import_memory_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import typer
from basic_memory.cli.app import import_app
from basic_memory.config import get_project_config
from basic_memory.config import ConfigManager, get_project_config
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
from basic_memory.markdown import EntityParser, MarkdownProcessor
from loguru import logger
Expand All @@ -20,8 +20,9 @@
async def get_markdown_processor() -> MarkdownProcessor:
"""Get MarkdownProcessor instance."""
config = get_project_config()
app_config = ConfigManager().config
entity_parser = EntityParser(config.home)
return MarkdownProcessor(entity_parser)
return MarkdownProcessor(entity_parser, app_config=app_config)


@import_app.command()
Expand Down
4 changes: 3 additions & 1 deletion src/basic_memory/cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ async def _set_default():
target_project = response.json()

# Use v2 API with project ID
response = await call_put(client, f"/v2/projects/{target_project['project_id']}/default")
response = await call_put(
client, f"/v2/projects/{target_project['project_id']}/default"
)
return ProjectStatusResponse.model_validate(response.json())

try:
Expand Down
22 changes: 22 additions & 0 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ class BasicMemoryConfig(BaseSettings):
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
)

# File formatting configuration
format_on_save: bool = Field(
default=False,
description="Automatically format files after saving using configured formatter. Disabled by default.",
)

formatter_command: Optional[str] = Field(
default=None,
description="External formatter command. Use {file} as placeholder for file path. If not set, uses built-in mdformat (Python, no Node.js required). Set to 'npx prettier --write {file}' for Prettier.",
)

formatters: Dict[str, str] = Field(
default_factory=dict,
description="Per-extension formatters. Keys are extensions (without dot), values are commands. Example: {'md': 'prettier --write {file}', 'json': 'prettier --write {file}'}",
)

formatter_timeout: float = Field(
default=5.0,
description="Maximum seconds to wait for formatter to complete",
gt=0,
)

# Project path constraints
project_root: Optional[str] = Field(
default=None,
Expand Down
Loading
Loading