Skip to content

Commit cd7cee6

Browse files
jope-bmclaude[bot]claude
authored
fix: complete project management special character support (#272) (#279)
Signed-off-by: Joe P <[email protected]> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: jope-bm <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 105bcaa commit cd7cee6

File tree

13 files changed

+180
-33
lines changed

13 files changed

+180
-33
lines changed

src/basic_memory/cli/commands/project.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
from basic_memory.schemas.project_info import ProjectStatusResponse
2424
from basic_memory.mcp.tools.utils import call_delete
2525
from basic_memory.mcp.tools.utils import call_put
26-
from basic_memory.mcp.tools.utils import call_patch
2726
from basic_memory.utils import generate_permalink
27+
from basic_memory.mcp.tools.utils import call_patch
2828

2929
console = Console()
3030

@@ -100,8 +100,8 @@ def remove_project(
100100
) -> None:
101101
"""Remove a project from configuration."""
102102
try:
103-
project_name = generate_permalink(name)
104-
response = asyncio.run(call_delete(client, f"/projects/{project_name}"))
103+
project_permalink = generate_permalink(name)
104+
response = asyncio.run(call_delete(client, f"/projects/{project_permalink}"))
105105
result = ProjectStatusResponse.model_validate(response.json())
106106

107107
console.print(f"[green]{result.message}[/green]")
@@ -119,9 +119,8 @@ def set_default_project(
119119
) -> None:
120120
"""Set the default project and activate it for the current session."""
121121
try:
122-
project_name = generate_permalink(name)
123-
124-
response = asyncio.run(call_put(client, f"/projects/{project_name}/default"))
122+
project_permalink = generate_permalink(name)
123+
response = asyncio.run(call_put(client, f"/projects/{project_permalink}/default"))
125124
result = ProjectStatusResponse.model_validate(response.json())
126125

127126
console.print(f"[green]{result.message}[/green]")
@@ -160,11 +159,11 @@ def move_project(
160159

161160
try:
162161
data = {"path": resolved_path}
163-
project_name = generate_permalink(name)
164162

163+
project_permalink = generate_permalink(name)
165164
current_project = session.get_current_project()
166165
response = asyncio.run(
167-
call_patch(client, f"/{current_project}/project/{project_name}", json=data)
166+
call_patch(client, f"/{current_project}/project/{project_permalink}", json=data)
168167
)
169168
result = ProjectStatusResponse.model_validate(response.json())
170169

src/basic_memory/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def set_default_project(self, name: str) -> None:
247247

248248
# Load config, modify, and save
249249
config = self.load_config()
250-
config.default_project = name
250+
config.default_project = project_name
251251
self.save_config(config)
252252

253253
def get_project(self, name: str) -> Tuple[str, str] | Tuple[None, None]:

src/basic_memory/mcp/tools/project_management.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,10 @@ async def set_default_project(project_name: str, ctx: Context | None = None) ->
221221
if ctx: # pragma: no cover
222222
await ctx.info(f"Setting default project to: {project_name}")
223223

224-
# Call API to set default project
225-
response = await call_put(client, f"/projects/{project_name}/default")
224+
# Call API to set default project using URL encoding for special characters
225+
from urllib.parse import quote
226+
encoded_name = quote(project_name, safe='')
227+
response = await call_put(client, f"/projects/{encoded_name}/default")
226228
status_response = ProjectStatusResponse.model_validate(response.json())
227229

228230
result = f"✓ {status_response.message}\n\n"
@@ -323,16 +325,29 @@ async def delete_project(project_name: str, ctx: Context | None = None) -> str:
323325
response = await call_get(client, "/projects/projects")
324326
project_list = ProjectList.model_validate(response.json())
325327

326-
# Check if project exists
327-
project_exists = any(p.name == project_name for p in project_list.projects)
328-
if not project_exists:
328+
# Find the project by name (case-insensitive) or permalink - same logic as switch_project
329+
project_permalink = generate_permalink(project_name)
330+
target_project = None
331+
for p in project_list.projects:
332+
# Match by permalink (handles case-insensitive input)
333+
if p.permalink == project_permalink:
334+
target_project = p
335+
break
336+
# Also match by name comparison (case-insensitive)
337+
if p.name.lower() == project_name.lower():
338+
target_project = p
339+
break
340+
341+
if not target_project:
329342
available_projects = [p.name for p in project_list.projects]
330343
raise ValueError(
331344
f"Project '{project_name}' not found. Available projects: {', '.join(available_projects)}"
332345
)
333346

334-
# Call API to delete project
335-
response = await call_delete(client, f"/projects/{project_name}")
347+
# Call API to delete project using URL encoding for special characters
348+
from urllib.parse import quote
349+
encoded_name = quote(target_project.name, safe='')
350+
response = await call_delete(client, f"/projects/{encoded_name}")
336351
status_response = ProjectStatusResponse.model_validate(response.json())
337352

338353
result = f"✓ {status_response.message}\n\n"

src/basic_memory/models/knowledge.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Knowledge graph models."""
22

3-
from datetime import datetime, timezone
3+
from datetime import datetime
44
from basic_memory.utils import ensure_timezone_aware
55
from typing import Optional
66

src/basic_memory/schemas/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import mimetypes
1515
import re
16-
from datetime import datetime, time, timezone
16+
from datetime import datetime, time
1717
from pathlib import Path
1818
from typing import List, Optional, Annotated, Dict
1919

src/basic_memory/services/project_service.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ async def remove_project(self, name: str) -> None:
139139
# First remove from config (this will validate the project exists and is not default)
140140
self.config_manager.remove_project(name)
141141

142-
# Then remove from database
143-
project = await self.repository.get_by_name(name)
142+
# Then remove from database using robust lookup
143+
project = await self.get_project(name)
144144
if project:
145145
await self.repository.delete(project.id)
146146

@@ -161,8 +161,8 @@ async def set_default_project(self, name: str) -> None:
161161
# First update config file (this will validate the project exists)
162162
self.config_manager.set_default_project(name)
163163

164-
# Then update database
165-
project = await self.repository.get_by_name(name)
164+
# Then update database using the same lookup logic as get_project
165+
project = await self.get_project(name)
166166
if project:
167167
await self.repository.set_as_default(project.id)
168168
else:
@@ -338,8 +338,8 @@ async def move_project(self, name: str, new_path: str) -> None:
338338
config.projects[name] = resolved_path
339339
self.config_manager.save_config(config)
340340

341-
# Update in database
342-
project = await self.repository.get_by_name(name)
341+
# Update in database using robust lookup
342+
project = await self.get_project(name)
343343
if project:
344344
await self.repository.update_path(project.id, resolved_path)
345345
logger.info(f"Moved project '{name}' from {old_path} to {resolved_path}")
@@ -370,8 +370,8 @@ async def update_project( # pragma: no cover
370370
if name not in self.config_manager.projects:
371371
raise ValueError(f"Project '{name}' not found in configuration")
372372

373-
# Get project from database
374-
project = await self.repository.get_by_name(name)
373+
# Get project from database using robust lookup
374+
project = await self.get_project(name)
375375
if not project:
376376
logger.error(f"Project '{name}' exists in config but not in database")
377377
return

test-int/mcp/test_project_management_integration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -604,15 +604,15 @@ async def test_create_delete_project_edge_cases(mcp_server, app):
604604
"""Test edge cases for create and delete project operations."""
605605

606606
async with Client(mcp_server) as client:
607-
# Test with special characters in project name (should be handled gracefully)
608-
special_name = "test-project-with-dashes"
607+
# Test with special characters and spaces in project name (should be handled gracefully)
608+
special_name = "test project with spaces & symbols!"
609609

610610
# Create project with special characters
611611
create_result = await client.call_tool(
612612
"create_memory_project",
613613
{
614614
"project_name": special_name,
615-
"project_path": f"/tmp/{special_name}",
615+
"project_path": "/tmp/test-project-with-special-chars",
616616
},
617617
)
618618
assert "✓" in create_result.content[0].text

tests/cli/test_project_commands.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,46 @@ def test_project_move_command_failure(mock_run, cli_env):
184184
# Should exit with code 1 and show error message
185185
assert result.exit_code == 1
186186
assert "Error moving project" in result.output
187+
188+
189+
@patch("basic_memory.cli.commands.project.call_patch")
190+
@patch("basic_memory.cli.commands.project.session")
191+
def test_project_move_command_uses_permalink(mock_session, mock_call_patch, cli_env):
192+
"""Test that the 'project move' command correctly generates and uses permalink in API call."""
193+
# Mock the session to return a current project
194+
mock_session.get_current_project.return_value = "current-project"
195+
196+
# Mock successful API response
197+
mock_response = MagicMock()
198+
mock_response.status_code = 200
199+
mock_response.json.return_value = {
200+
"message": "Project 'Test Project Name' updated successfully",
201+
"status": "success",
202+
"default": False,
203+
}
204+
mock_call_patch.return_value = mock_response
205+
206+
runner = CliRunner()
207+
208+
# Test with a project name that needs normalization (spaces, mixed case)
209+
project_name = "Test Project Name"
210+
new_path = os.path.join("new", "path", "to", "project")
211+
212+
result = runner.invoke(cli_app, ["project", "move", project_name, new_path])
213+
214+
# Verify command executed successfully
215+
assert result.exit_code == 0
216+
217+
# Verify call_patch was called with the correct permalink-formatted project name
218+
mock_call_patch.assert_called_once()
219+
args, kwargs = mock_call_patch.call_args
220+
221+
# Check the API endpoint uses the normalized permalink
222+
expected_endpoint = "/current-project/project/test-project-name"
223+
assert args[1] == expected_endpoint # Second argument is the endpoint URL
224+
225+
# Verify the data contains the resolved path (using same normalization as the function)
226+
from pathlib import Path
227+
expected_path = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
228+
expected_data = {"path": expected_path}
229+
assert kwargs["json"] == expected_data

tests/schemas/test_memory_serialization.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import json
44
from datetime import datetime
55

6-
import pytest
76

87
from basic_memory.schemas.memory import (
98
EntitySummary,

tests/schemas/test_schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Tests for Pydantic schema validation and conversion."""
22

33
import pytest
4-
from datetime import datetime, time, timedelta, timezone
4+
from datetime import datetime, time, timedelta
55
from pydantic import ValidationError, BaseModel
66

77
from basic_memory.schemas import (

0 commit comments

Comments
 (0)