fix: Windows CLI Unicode encoding errors (#411)

Signed-off-by: phernandez <paul@basicmachines.co>
Signed-off-by: Claude <noreply@anthropic.com>
Signed-off-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
Co-authored-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
This commit is contained in:
Drew Cain
2025-11-02 08:20:48 -08:00
committed by GitHub
parent 0b3272ae6e
commit 0ba6f219f1
14 changed files with 63 additions and 59 deletions
+4 -1
View File
@@ -71,9 +71,12 @@ jobs:
- [ ] Proper error handling and logging
- [ ] Performance considerations addressed
- [ ] No sensitive data in logs or commits
## Compatability
- [ ] File path comparisons must be windows compatible
- [ ] Avoid using emojis and unicode characters in console and log output
Read the CLAUDE.md file for detailed project context. For each checklist item, verify if it's satisfied and comment on any that need attention. Use inline comments for specific code issues and post a summary with checklist results.
# Allow broader tool access for thorough code review
claude_args: '--allowed-tools "Bash(gh pr:*),Bash(gh issue:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Read,Grep,Glob"'
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.15.2"
__version__ = "0.15.3.dev42+36149d6"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+5 -5
View File
@@ -77,7 +77,7 @@ class CLIAuth:
verification_uri = device_response["verification_uri"]
verification_uri_complete = device_response.get("verification_uri_complete")
console.print("\n[bold blue]🔐 Authentication Required[/bold blue]")
console.print("\n[bold blue]Authentication Required[/bold blue]")
console.print("\nTo authenticate, please visit:")
console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
@@ -171,7 +171,7 @@ class CLIAuth:
# Secure the token file
os.chmod(self.token_file, 0o600)
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
def load_tokens(self) -> dict | None:
"""Load tokens from .bm-auth.json file."""
@@ -233,7 +233,7 @@ class CLIAuth:
if new_tokens:
# Save new tokens (may include rotated refresh token)
self.save_tokens(new_tokens)
console.print("[green]Token refreshed successfully[/green]")
console.print("[green]Token refreshed successfully[/green]")
return new_tokens["access_token"]
else:
console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
@@ -265,13 +265,13 @@ class CLIAuth:
# Step 4: Save tokens
self.save_tokens(tokens)
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
return True
def logout(self) -> None:
"""Remove stored authentication tokens."""
if self.token_file.exists():
self.token_file.unlink()
console.print("[green]Logged out successfully[/green]")
console.print("[green]Logged out successfully[/green]")
else:
console.print("[yellow]No stored authentication found[/yellow]")
@@ -52,11 +52,11 @@ def login():
config.cloud_mode = True
config_manager.save_config(config)
console.print("[green]Cloud mode enabled[/green]")
console.print("[green]Cloud mode enabled[/green]")
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
console.print(
@@ -77,7 +77,7 @@ def logout():
config.cloud_mode = False
config_manager.save_config(config)
console.print("[green]Cloud mode disabled[/green]")
console.print("[green]Cloud mode disabled[/green]")
console.print("[dim]All CLI commands now work locally[/dim]")
@@ -157,12 +157,12 @@ def setup() -> None:
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
console.print("[green]Generated secure credentials[/green]")
console.print("[green]Generated secure credentials[/green]")
# Step 4: Configure rclone remote
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
@@ -171,7 +171,7 @@ def setup() -> None:
secret_key=creds.secret_key,
)
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
console.print("\n[bold]Next steps:[/bold]")
console.print("1. Add a project with local sync path:")
console.print(" bm project add research --local-path ~/Documents/research")
@@ -106,5 +106,5 @@ def configure_rclone_remote(
# Save updated config
save_rclone_config(config)
console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
return REMOTE_NAME
@@ -58,7 +58,7 @@ def install_rclone_macos() -> None:
try:
console.print("[blue]Installing rclone via Homebrew...[/blue]")
run_command(["brew", "install", "rclone"])
console.print("[green]rclone installed via Homebrew[/green]")
console.print("[green]rclone installed via Homebrew[/green]")
return
except RcloneInstallError:
console.print(
@@ -69,7 +69,7 @@ def install_rclone_macos() -> None:
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: brew install rclone"
@@ -83,7 +83,7 @@ def install_rclone_linux() -> None:
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError:
console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
@@ -94,7 +94,7 @@ def install_rclone_linux() -> None:
console.print("[blue]Installing rclone via apt...[/blue]")
run_command(["sudo", "apt", "update"])
run_command(["sudo", "apt", "install", "-y", "rclone"])
console.print("[green]rclone installed via apt[/green]")
console.print("[green]rclone installed via apt[/green]")
return
except RcloneInstallError:
console.print("[yellow]apt installation failed, trying official script...[/yellow]")
@@ -103,7 +103,7 @@ def install_rclone_linux() -> None:
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: sudo snap install rclone"
@@ -117,7 +117,7 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via winget...[/blue]")
run_command(["winget", "install", "Rclone.Rclone"])
console.print("[green]rclone installed via winget[/green]")
console.print("[green]rclone installed via winget[/green]")
return
except RcloneInstallError:
console.print("[yellow]winget installation failed, trying chocolatey...[/yellow]")
@@ -127,7 +127,7 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via chocolatey...[/blue]")
run_command(["choco", "install", "rclone", "-y"])
console.print("[green]rclone installed via chocolatey[/green]")
console.print("[green]rclone installed via chocolatey[/green]")
return
except RcloneInstallError:
console.print("[yellow]chocolatey installation failed, trying scoop...[/yellow]")
@@ -137,7 +137,7 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via scoop...[/blue]")
run_command(["scoop", "install", "rclone"])
console.print("[green]rclone installed via scoop[/green]")
console.print("[green]rclone installed via scoop[/green]")
return
except RcloneInstallError:
console.print("[yellow]scoop installation failed[/yellow]")
@@ -172,7 +172,7 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
if not is_rclone_installed():
raise RcloneInstallError("rclone installation completed but command not found in PATH")
console.print("[green]rclone installation completed successfully[/green]")
console.print("[green]rclone installation completed successfully[/green]")
except RcloneInstallError:
raise
@@ -108,7 +108,7 @@ async def upload_path(
if dry_run:
print(f"\nTotal: {len(files_to_upload)} file(s) ({size_str})")
else:
print(f"Upload complete: {len(files_to_upload)} file(s) ({size_str})")
print(f"Upload complete: {len(files_to_upload)} file(s) ({size_str})")
return True
@@ -78,7 +78,7 @@ def upload(
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
try:
await create_cloud_project(project)
console.print(f"[green]Created project '{project}'[/green]")
console.print(f"[green]Created project '{project}'[/green]")
except Exception as e:
console.print(f"[red]Failed to create project: {e}[/red]")
raise typer.Exit(1)
@@ -109,7 +109,7 @@ def upload(
if dry_run:
console.print("[yellow]DRY RUN complete - no files were uploaded[/yellow]")
else:
console.print(f"[green]Successfully uploaded to '{project}'[/green]")
console.print(f"[green]Successfully uploaded to '{project}'[/green]")
# Sync project if requested (skip on dry run)
# Force full scan after bisync to ensure database is up-to-date with synced files
@@ -32,9 +32,9 @@ async def run_sync(project: Optional[str] = None, force_full: bool = False):
url += "?force_full=true"
response = await call_post(client, url)
data = response.json()
console.print(f"[green]{data['message']}[/green]")
console.print(f"[green]{data['message']}[/green]")
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
console.print(f"[red]Sync failed: {e}[/red]")
raise typer.Exit(1)
@@ -47,5 +47,5 @@ async def get_project_info(project: str):
response = await call_get(client, f"{project_item.project_url}/project/info")
return ProjectInfoResponse.model_validate(response.json())
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
console.print(f"[red]Sync failed: {e}[/red]")
raise typer.Exit(1)
+22 -22
View File
@@ -78,7 +78,7 @@ def list_projects() -> None:
table.add_column("Default", style="magenta")
for project in result.projects:
is_default = "" if project.is_default else ""
is_default = "[X]" if project.is_default else ""
normalized_path = normalize_project_path(project.path)
# Build row based on mode
@@ -179,7 +179,7 @@ def add_project(
)
ConfigManager().save_config(config)
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
@@ -233,7 +233,7 @@ def setup_project_sync(
)
config_manager.save_config(config)
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"\nLocal sync path: {resolved_path}")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
@@ -286,7 +286,7 @@ def remove_project(
import shutil
shutil.rmtree(local_dir)
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
# Clean up bisync state if it exists
if has_bisync_state:
@@ -296,7 +296,7 @@ def remove_project(
bisync_state_path = get_project_bisync_state(name)
if bisync_state_path.exists():
shutil.rmtree(bisync_state_path)
console.print("[green]Removed bisync state[/green]")
console.print("[green]Removed bisync state[/green]")
# Clean up cloud_projects config entry
if config.cloud_mode_enabled and name in config.cloud_projects:
@@ -407,7 +407,7 @@ def move_project(
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
f"[cyan]{resolved_path}[/cyan]\n\n"
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
title="⚠️ Manual File Movement Required",
title="Manual File Movement Required",
border_style="yellow",
expand=False,
)
@@ -477,7 +477,7 @@ def sync_project_command(
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
if success:
console.print(f"[green]{name} synced successfully[/green]")
console.print(f"[green]{name} synced successfully[/green]")
# Trigger database sync if not a dry run
if not dry_run:
@@ -494,7 +494,7 @@ def sync_project_command(
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} sync failed[/red]")
console.print(f"[red]{name} sync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
@@ -512,7 +512,7 @@ def bisync_project_command(
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Two-way sync: local cloud (bidirectional sync).
"""Two-way sync: local <-> cloud (bidirectional sync).
Examples:
bm project bisync --name research --resync # First time
@@ -562,13 +562,13 @@ def bisync_project_command(
)
# Run bisync
console.print(f"[blue]Bisync {name} (local cloud)...[/blue]")
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
success = project_bisync(
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
)
if success:
console.print(f"[green]{name} bisync completed successfully[/green]")
console.print(f"[green]{name} bisync completed successfully[/green]")
# Update config
config.cloud_projects[name].last_sync = datetime.now()
@@ -590,7 +590,7 @@ def bisync_project_command(
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} bisync failed[/red]")
console.print(f"[red]{name} bisync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
@@ -658,9 +658,9 @@ def check_project_command(
match = project_check(sync_project, bucket_name, one_way=one_way)
if match:
console.print(f"[green]{name} files match[/green]")
console.print(f"[green]{name} files match[/green]")
else:
console.print(f"[yellow]{name} has differences[/yellow]")
console.print(f"[yellow]!{name} has differences[/yellow]")
except RcloneError as e:
console.print(f"[red]Check error: {e}[/red]")
@@ -691,7 +691,7 @@ def bisync_reset(
# Remove the entire state directory
shutil.rmtree(state_path)
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
@@ -782,13 +782,13 @@ def display_project_info(
f"[bold]Project:[/bold] {info.project_name}\n"
f"[bold]Path:[/bold] {info.project_path}\n"
f"[bold]Default Project:[/bold] {info.default_project}\n",
title="📊 Basic Memory Project Info",
title="Basic Memory Project Info",
expand=False,
)
)
# Statistics section
stats_table = Table(title="📈 Statistics")
stats_table = Table(title="Statistics")
stats_table.add_column("Metric", style="cyan")
stats_table.add_column("Count", style="green")
@@ -804,7 +804,7 @@ def display_project_info(
# Entity types
if info.statistics.entity_types:
entity_types_table = Table(title="📑 Entity Types")
entity_types_table = Table(title="Entity Types")
entity_types_table.add_column("Type", style="blue")
entity_types_table.add_column("Count", style="green")
@@ -815,7 +815,7 @@ def display_project_info(
# Most connected entities
if info.statistics.most_connected_entities: # pragma: no cover
connected_table = Table(title="🔗 Most Connected Entities")
connected_table = Table(title="Most Connected Entities")
connected_table.add_column("Title", style="blue")
connected_table.add_column("Permalink", style="cyan")
connected_table.add_column("Relations", style="green")
@@ -829,7 +829,7 @@ def display_project_info(
# Recent activity
if info.activity.recently_updated: # pragma: no cover
recent_table = Table(title="🕒 Recent Activity")
recent_table = Table(title="Recent Activity")
recent_table.add_column("Title", style="blue")
recent_table.add_column("Type", style="cyan")
recent_table.add_column("Last Updated", style="green")
@@ -849,7 +849,7 @@ def display_project_info(
console.print(recent_table)
# Available projects
projects_table = Table(title="📁 Available Projects")
projects_table = Table(title="Available Projects")
projects_table.add_column("Name", style="blue")
projects_table.add_column("Path", style="cyan")
projects_table.add_column("Default", style="green")
@@ -857,7 +857,7 @@ def display_project_info(
for name, proj_info in info.available_projects.items():
is_default = name == info.default_project
project_path = proj_info["path"]
projects_table.add_row(name, project_path, "" if is_default else "")
projects_table.add_row(name, project_path, "[X]" if is_default else "")
console.print(projects_table)
+3 -3
View File
@@ -115,7 +115,7 @@ def display_changes(
del_branch = tree.add("[red]Deleted[/red]")
add_files_to_tree(del_branch, changes.deleted, "red")
if changes.skipped_files:
skip_branch = tree.add("[red]⚠️ Skipped (Circuit Breaker)[/red]")
skip_branch = tree.add("[red]! Skipped (Circuit Breaker)[/red]")
for skipped in sorted(changes.skipped_files, key=lambda x: x.path):
skip_branch.add(
f"[red]{skipped.path}[/red] "
@@ -133,7 +133,7 @@ def display_changes(
if changes.skipped_files:
skip_count = len(changes.skipped_files)
tree.add(
f"[red]⚠️ {skip_count} file{'s' if skip_count != 1 else ''} "
f"[red]! {skip_count} file{'s' if skip_count != 1 else ''} "
f"skipped due to repeated failures[/red]"
)
@@ -152,7 +152,7 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p
display_changes(project_item.name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
console.print(f"[red]Error: {e}[/red]")
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@@ -19,7 +19,7 @@ def test_project_list(app_config, test_project, config_manager):
print(f"Exception: {result.exception}")
assert result.exit_code == 0
assert "test-project" in result.stdout
assert "" in result.stdout # default marker
assert "[X]" in result.stdout # default marker
def test_project_info(app_config, test_project, config_manager):
@@ -114,8 +114,8 @@ def test_project_set_default(app_config, config_manager):
# Verify in list
result = runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
# The new project should have the checkmark now
# The new project should have the [X] marker now
lines = result.stdout.split("\n")
for line in lines:
if "another-project" in line:
assert "" in line
assert "[X]" in line
+1 -1
View File
@@ -348,7 +348,7 @@ class TestTimeframeParsing:
result_1d = parse_timeframe("1d")
expected_1d = now - timedelta(days=1)
diff = abs((result_1d - expected_1d).total_seconds())
assert diff < 60 # Within 1 minute tolerance
assert diff < 3600 # Within 1 hour tolerance (accounts for DST transitions)
assert result_1d.tzinfo is not None
# Test yesterday - should be yesterday at same time
+2 -1
View File
@@ -179,12 +179,13 @@ async def test_force_full_bypasses_watermark_optimization(
# Modify a file WITHOUT updating mtime (simulates external tool like rclone)
# We set mtime to be BEFORE the watermark to ensure incremental scan won't detect it
file_path = project_dir / "file1.md"
original_stat = file_path.stat()
file_path.stat()
await create_test_file(file_path, "# File 1\nModified by external tool")
# Set mtime to be before the watermark (use time from before first sync)
# This simulates rclone bisync which may preserve original timestamps
import os
old_time = initial_timestamp - 10 # 10 seconds before watermark
os.utime(file_path, (old_time, old_time))