Skip to content

Commit 8333e93

Browse files
divideby0claude
andcommitted
feat: add auto-format files on save
Add optional automatic file formatting using external formatters (like Prettier) when Basic Memory writes files. Includes a standalone `basic-memory format` command for batch formatting existing files. Configuration options: - format_on_save: Enable/disable auto-formatting (default: false) - formatter_command: Global formatter command (e.g., "prettier --write {file}") - formatters: Per-extension formatters (e.g., {"md": "prettier --write {file}"}) - formatter_timeout: Max seconds to wait for formatter (default: 5.0) Integration points: - FileService.write_file() and update_frontmatter() - MarkdownProcessor.write_file() - All import commands Closes #466 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Cedric Hurst <cedric@spantree.net>
1 parent d3fb77b commit 8333e93

14 files changed

Lines changed: 690 additions & 29 deletions

src/basic_memory/cli/commands/__init__.py

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

33
from . import status, db, import_memory_json, mcp, import_claude_conversations
4-
from . import import_claude_projects, import_chatgpt, tool, project
4+
from . import import_claude_projects, import_chatgpt, tool, project, format
55

66
__all__ = [
77
"status",
@@ -13,4 +13,5 @@
1313
"import_chatgpt",
1414
"tool",
1515
"project",
16+
"format",
1617
]
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Format command for basic-memory CLI."""
2+
3+
import asyncio
4+
from pathlib import Path
5+
from typing import Annotated, Optional
6+
7+
import typer
8+
from loguru import logger
9+
from rich.console import Console
10+
from rich.progress import Progress, SpinnerColumn, TextColumn
11+
12+
from basic_memory.cli.app import app
13+
from basic_memory.config import ConfigManager, get_project_config
14+
from basic_memory.file_utils import format_file
15+
16+
console = Console()
17+
18+
19+
async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]:
20+
"""Format a single file.
21+
22+
Returns:
23+
Tuple of (path, success, error_message)
24+
"""
25+
try:
26+
result = await format_file(file_path, app_config)
27+
if result is not None:
28+
return (file_path, True, None)
29+
else:
30+
return (file_path, False, "No formatter configured or formatting skipped")
31+
except Exception as e:
32+
return (file_path, False, str(e))
33+
34+
35+
async def format_files(
36+
paths: list[Path], app_config, show_progress: bool = True
37+
) -> tuple[int, int, list[tuple[Path, str]]]:
38+
"""Format multiple files.
39+
40+
Returns:
41+
Tuple of (formatted_count, skipped_count, errors)
42+
"""
43+
formatted = 0
44+
skipped = 0
45+
errors: list[tuple[Path, str]] = []
46+
47+
if show_progress:
48+
with Progress(
49+
SpinnerColumn(),
50+
TextColumn("[progress.description]{task.description}"),
51+
console=console,
52+
) as progress:
53+
task = progress.add_task("Formatting files...", total=len(paths))
54+
55+
for file_path in paths:
56+
path, success, error = await format_single_file(file_path, app_config)
57+
if success:
58+
formatted += 1
59+
elif error and "No formatter configured" not in error:
60+
errors.append((path, error))
61+
else:
62+
skipped += 1
63+
progress.update(task, advance=1)
64+
else:
65+
for file_path in paths:
66+
path, success, error = await format_single_file(file_path, app_config)
67+
if success:
68+
formatted += 1
69+
elif error and "No formatter configured" not in error:
70+
errors.append((path, error))
71+
else:
72+
skipped += 1
73+
74+
return formatted, skipped, errors
75+
76+
77+
async def run_format(
78+
path: Optional[Path] = None,
79+
project: Optional[str] = None,
80+
) -> None:
81+
"""Run the format command."""
82+
app_config = ConfigManager().config
83+
84+
# Check if formatting is enabled
85+
if not app_config.format_on_save and not app_config.formatter_command and not app_config.formatters:
86+
console.print(
87+
"[yellow]No formatters configured. Set format_on_save=true and "
88+
"formatter_command or formatters in your config.[/yellow]"
89+
)
90+
console.print(
91+
"\nExample config (~/.basic-memory/config.json):\n"
92+
' "format_on_save": true,\n'
93+
' "formatter_command": "prettier --write {file}"\n'
94+
)
95+
raise typer.Exit(1)
96+
97+
# Temporarily enable format_on_save for this command
98+
# (so format_file actually runs the formatter)
99+
original_format_on_save = app_config.format_on_save
100+
app_config.format_on_save = True
101+
102+
try:
103+
# Determine which files to format
104+
if path:
105+
# Format specific file or directory
106+
if path.is_file():
107+
files = [path]
108+
elif path.is_dir():
109+
# Find all markdown and json files
110+
files = list(path.rglob("*.md")) + list(path.rglob("*.json")) + list(path.rglob("*.canvas"))
111+
else:
112+
console.print(f"[red]Path not found: {path}[/red]")
113+
raise typer.Exit(1)
114+
else:
115+
# Format all files in project
116+
project_config = get_project_config(project)
117+
project_path = Path(project_config.home)
118+
119+
if not project_path.exists():
120+
console.print(f"[red]Project path not found: {project_path}[/red]")
121+
raise typer.Exit(1)
122+
123+
# Find all markdown and json files
124+
files = (
125+
list(project_path.rglob("*.md"))
126+
+ list(project_path.rglob("*.json"))
127+
+ list(project_path.rglob("*.canvas"))
128+
)
129+
130+
if not files:
131+
console.print("[yellow]No files found to format.[/yellow]")
132+
return
133+
134+
console.print(f"Found {len(files)} file(s) to format...")
135+
136+
formatted, skipped, errors = await format_files(files, app_config)
137+
138+
# Print summary
139+
console.print()
140+
if formatted > 0:
141+
console.print(f"[green]Formatted: {formatted} file(s)[/green]")
142+
if skipped > 0:
143+
console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]")
144+
if errors:
145+
console.print(f"[red]Errors: {len(errors)} file(s)[/red]")
146+
for path, error in errors:
147+
console.print(f" [red]{path}[/red]: {error}")
148+
149+
finally:
150+
# Restore original setting
151+
app_config.format_on_save = original_format_on_save
152+
153+
154+
@app.command()
155+
def format(
156+
path: Annotated[
157+
Optional[Path],
158+
typer.Argument(help="File or directory to format. Defaults to current project."),
159+
] = None,
160+
project: Annotated[
161+
Optional[str],
162+
typer.Option("--project", "-p", help="Project name to format."),
163+
] = None,
164+
) -> None:
165+
"""Format files using configured formatters.
166+
167+
Uses the formatter_command or formatters settings from your config.
168+
By default, formats all .md, .json, and .canvas files in the current project.
169+
170+
Examples:
171+
basic-memory format # Format all files in current project
172+
basic-memory format --project research # Format files in specific project
173+
basic-memory format notes/meeting.md # Format a specific file
174+
basic-memory format notes/ # Format all files in directory
175+
"""
176+
try:
177+
asyncio.run(run_format(path, project))
178+
except Exception as e:
179+
if not isinstance(e, typer.Exit):
180+
logger.error(f"Error formatting files: {e}")
181+
console.print(f"[red]Error formatting files: {e}[/red]")
182+
raise typer.Exit(code=1)
183+
raise

src/basic_memory/cli/commands/import_chatgpt.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import typer
99
from basic_memory.cli.app import import_app
10-
from basic_memory.config import get_project_config
10+
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers import ChatGPTImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
1313
from loguru import logger
@@ -20,8 +20,9 @@
2020
async def get_markdown_processor() -> MarkdownProcessor:
2121
"""Get MarkdownProcessor instance."""
2222
config = get_project_config()
23+
app_config = ConfigManager().config
2324
entity_parser = EntityParser(config.home)
24-
return MarkdownProcessor(entity_parser)
25+
return MarkdownProcessor(entity_parser, app_config=app_config)
2526

2627

2728
@import_app.command(name="chatgpt", help="Import conversations from ChatGPT JSON export.")

src/basic_memory/cli/commands/import_claude_conversations.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import typer
99
from basic_memory.cli.app import claude_app
10-
from basic_memory.config import get_project_config
10+
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers.claude_conversations_importer import ClaudeConversationsImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
1313
from loguru import logger
@@ -20,8 +20,9 @@
2020
async def get_markdown_processor() -> MarkdownProcessor:
2121
"""Get MarkdownProcessor instance."""
2222
config = get_project_config()
23+
app_config = ConfigManager().config
2324
entity_parser = EntityParser(config.home)
24-
return MarkdownProcessor(entity_parser)
25+
return MarkdownProcessor(entity_parser, app_config=app_config)
2526

2627

2728
@claude_app.command(name="conversations", help="Import chat conversations from Claude.ai.")

src/basic_memory/cli/commands/import_claude_projects.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import typer
99
from basic_memory.cli.app import claude_app
10-
from basic_memory.config import get_project_config
10+
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers.claude_projects_importer import ClaudeProjectsImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
1313
from loguru import logger
@@ -20,8 +20,9 @@
2020
async def get_markdown_processor() -> MarkdownProcessor:
2121
"""Get MarkdownProcessor instance."""
2222
config = get_project_config()
23+
app_config = ConfigManager().config
2324
entity_parser = EntityParser(config.home)
24-
return MarkdownProcessor(entity_parser)
25+
return MarkdownProcessor(entity_parser, app_config=app_config)
2526

2627

2728
@claude_app.command(name="projects", help="Import projects from Claude.ai.")

src/basic_memory/cli/commands/import_memory_json.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import typer
99
from basic_memory.cli.app import import_app
10-
from basic_memory.config import get_project_config
10+
from basic_memory.config import ConfigManager, get_project_config
1111
from basic_memory.importers.memory_json_importer import MemoryJsonImporter
1212
from basic_memory.markdown import EntityParser, MarkdownProcessor
1313
from loguru import logger
@@ -20,8 +20,9 @@
2020
async def get_markdown_processor() -> MarkdownProcessor:
2121
"""Get MarkdownProcessor instance."""
2222
config = get_project_config()
23+
app_config = ConfigManager().config
2324
entity_parser = EntityParser(config.home)
24-
return MarkdownProcessor(entity_parser)
25+
return MarkdownProcessor(entity_parser, app_config=app_config)
2526

2627

2728
@import_app.command()

src/basic_memory/config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ class BasicMemoryConfig(BaseSettings):
165165
description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.",
166166
)
167167

168+
# File formatting configuration
169+
format_on_save: bool = Field(
170+
default=False,
171+
description="Automatically format files after saving using configured formatter. Disabled by default.",
172+
)
173+
174+
formatter_command: Optional[str] = Field(
175+
default=None,
176+
description="Default formatter command. Use {file} as placeholder for file path. Example: 'prettier --write {file}'",
177+
)
178+
179+
formatters: Dict[str, str] = Field(
180+
default_factory=dict,
181+
description="Per-extension formatters. Keys are extensions (without dot), values are commands. Example: {'md': 'prettier --write {file}', 'json': 'prettier --write {file}'}",
182+
)
183+
184+
formatter_timeout: float = Field(
185+
default=5.0,
186+
description="Maximum seconds to wait for formatter to complete",
187+
gt=0,
188+
)
189+
168190
# Project path constraints
169191
project_root: Optional[str] = Field(
170192
default=None,

src/basic_memory/deps.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,24 +351,32 @@ async def get_entity_parser_v2(project_config: ProjectConfigV2Dep) -> EntityPars
351351
EntityParserV2Dep = Annotated["EntityParser", Depends(get_entity_parser_v2)]
352352

353353

354-
async def get_markdown_processor(entity_parser: EntityParserDep) -> MarkdownProcessor:
355-
return MarkdownProcessor(entity_parser)
354+
async def get_markdown_processor(
355+
entity_parser: EntityParserDep, app_config: AppConfigDep
356+
) -> MarkdownProcessor:
357+
return MarkdownProcessor(entity_parser, app_config=app_config)
356358

357359

358360
MarkdownProcessorDep = Annotated[MarkdownProcessor, Depends(get_markdown_processor)]
359361

360362

361-
async def get_markdown_processor_v2(entity_parser: EntityParserV2Dep) -> MarkdownProcessor:
362-
return MarkdownProcessor(entity_parser)
363+
async def get_markdown_processor_v2(
364+
entity_parser: EntityParserV2Dep, app_config: AppConfigDep
365+
) -> MarkdownProcessor:
366+
return MarkdownProcessor(entity_parser, app_config=app_config)
363367

364368

365369
MarkdownProcessorV2Dep = Annotated[MarkdownProcessor, Depends(get_markdown_processor_v2)]
366370

367371

368372
async def get_file_service(
369-
project_config: ProjectConfigDep, markdown_processor: MarkdownProcessorDep
373+
project_config: ProjectConfigDep,
374+
markdown_processor: MarkdownProcessorDep,
375+
app_config: AppConfigDep,
370376
) -> FileService:
371-
file_service = FileService(project_config.home, markdown_processor)
377+
file_service = FileService(
378+
project_config.home, markdown_processor, app_config=app_config
379+
)
372380
logger.debug(
373381
f"Created FileService for project: {project_config.name}, base_path: {project_config.home} "
374382
)
@@ -379,9 +387,13 @@ async def get_file_service(
379387

380388

381389
async def get_file_service_v2(
382-
project_config: ProjectConfigV2Dep, markdown_processor: MarkdownProcessorV2Dep
390+
project_config: ProjectConfigV2Dep,
391+
markdown_processor: MarkdownProcessorV2Dep,
392+
app_config: AppConfigDep,
383393
) -> FileService:
384-
file_service = FileService(project_config.home, markdown_processor)
394+
file_service = FileService(
395+
project_config.home, markdown_processor, app_config=app_config
396+
)
385397
logger.debug(
386398
f"Created FileService for project: {project_config.name}, base_path: {project_config.home}"
387399
)

0 commit comments

Comments
 (0)