Skip to content

Commit 1fd680c

Browse files
phernandezdivideby0claudefeniixCedric Hurst
authored
feat: add auto-format files on save with built-in Python formatter (#474)
Signed-off-by: Cedric Hurst <[email protected]> Signed-off-by: phernandez <[email protected]> Co-authored-by: Cedric Hurst <[email protected]> Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: Sebastian B Otaegui <[email protected]> Co-authored-by: Cedric Hurst <[email protected]>
1 parent 38919d1 commit 1fd680c

33 files changed

+1249
-124
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ dependencies = [
3838
"nest-asyncio>=1.6.0", # For Alembic migrations with Postgres
3939
"pytest-asyncio>=1.2.0",
4040
"psycopg==3.3.1",
41+
"mdformat>=0.7.22",
42+
"mdformat-gfm>=0.3.7",
43+
"mdformat-frontmatter>=2.0.8",
4144
]
4245

4346

src/basic_memory/api/v2/routers/knowledge_router.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,7 @@ async def resolve_identifier(
9696
# Try to resolve the identifier
9797
entity = await link_resolver.resolve_link(data.identifier)
9898
if not entity:
99-
raise HTTPException(
100-
status_code=404, detail=f"Entity not found: '{data.identifier}'"
101-
)
99+
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
102100

103101
# Determine resolution method
104102
resolution_method = "search" # default

src/basic_memory/api/v2/routers/project_router.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,7 @@ async def resolve_project_identifier(
9595
resolution_method = "name"
9696

9797
if not project:
98-
raise HTTPException(
99-
status_code=404, detail=f"Project not found: '{data.identifier}'"
100-
)
98+
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")
10199

102100
return ProjectResolveResponse(
103101
project_id=project.id,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,9 @@ async def _set_default():
341341
target_project = response.json()
342342

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

347349
try:

0 commit comments

Comments
 (0)