feat(cli): per-workspace rclone remotes for Team push/pull (#920)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-06-08 17:39:37 -05:00
committed by GitHub
parent 4128cac9ab
commit de53e0ecc5
8 changed files with 505 additions and 84 deletions
@@ -32,14 +32,33 @@ def _rclone_exclude_filters(pattern: str) -> list[str]:
return [f"- {path_pattern}", f"- {path_pattern}/**"]
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
def _workspace_id_header(workspace_id: str | None) -> dict[str, str]:
"""Header that routes a /tenant/mount/* request to a specific tenant.
The mount endpoints resolve the workspace from X-Workspace-ID (validating
membership + subscription) and fall back to the user's default tenant when
it is absent — so omitting it preserves the original default-tenant behavior.
"""
return {"X-Workspace-ID": workspace_id} if workspace_id else {}
async def get_mount_info(*, workspace_id: str | None = None) -> TenantMountInfo:
"""Get tenant mount info (bucket name + tenant id) from the cloud API.
Args:
workspace_id: Tenant id of the target workspace. When omitted, the API
uses the authenticated user's default tenant.
"""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
response = await make_api_request(
method="GET",
url=f"{host_url}/tenant/mount/info",
headers=_workspace_id_header(workspace_id),
)
return TenantMountInfo.model_validate(response.json())
except Exception as e:
@@ -47,13 +66,24 @@ async def get_mount_info() -> TenantMountInfo:
async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
"""Generate scoped credentials for syncing."""
"""Generate scoped S3 credentials for syncing a specific tenant's bucket.
Args:
tenant_id: Tenant id whose bucket-scoped credentials to mint. Routed via
X-Workspace-ID so team workspaces get their own bucket's credentials.
"""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
# The mount endpoints resolve X-Workspace-ID by matching the workspace's
# tenant_id, so passing a tenant_id here is the correct routing key.
response = await make_api_request(
method="POST",
url=f"{host_url}/tenant/mount/credentials",
headers=_workspace_id_header(tenant_id),
)
return MountCredentials.model_validate(response.json())
except Exception as e:
@@ -26,15 +26,51 @@ from basic_memory.cli.commands.cloud.bisync_commands import (
generate_mount_credentials,
get_mount_info,
)
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
from basic_memory.cli.commands.cloud.rclone_config import (
configure_rclone_remote,
remote_name_for_workspace,
)
from basic_memory.cli.commands.cloud.rclone_installer import (
RcloneInstallError,
install_rclone,
)
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.schemas.cloud import (
WorkspaceInfo,
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_exact_identifier,
)
console = Console()
def _resolve_setup_workspace(identifier: str) -> WorkspaceInfo:
"""Resolve a workspace identifier (slug, name, or tenant_id) for setup.
Errors with copyable choices when the identifier matches zero or multiple
workspaces, so the user can disambiguate.
"""
workspaces = run_with_cleanup(get_available_workspaces())
if not workspaces:
console.print("[red]No accessible cloud workspaces found for this account[/red]")
raise typer.Exit(1)
matches = [ws for ws in workspaces if workspace_matches_exact_identifier(ws, identifier)]
if len(matches) == 1:
return matches[0]
if not matches:
console.print(f"[red]No workspace matches '{identifier}'[/red]")
console.print("\nAvailable workspaces:")
console.print(format_workspace_choices(workspaces))
else:
console.print(f"[red]'{identifier}' matches multiple workspaces[/red]")
console.print("\nDisambiguate with the workspace slug or tenant_id:")
console.print(format_workspace_selection_choices(matches))
raise typer.Exit(1)
@cloud_app.command()
def login():
"""Authenticate with WorkOS using OAuth Device Authorization flow."""
@@ -164,13 +200,23 @@ def status() -> None:
@cloud_app.command("setup")
def setup() -> None:
def setup(
workspace: str | None = typer.Option(
None,
"--workspace",
help="Set up sync for a specific workspace (slug, name, or tenant_id). "
"Omit for your default workspace.",
),
) -> None:
"""Set up cloud sync by installing rclone and configuring credentials.
After setup, use project commands for syncing:
bm project add <name> --cloud --local-path ~/projects/<name>
bm project bisync --name <name> --resync # First time
bm project bisync --name <name> # Subsequent syncs
Run once per workspace you sync. The default workspace uses the
'basic-memory-cloud' remote; other (e.g. Team) workspaces each get their own
tenant-scoped remote, since Tigris credentials are bucket-scoped.
After setup, use the cloud sync commands:
bm cloud pull --name <name> # fetch cloud changes (Team-safe)
bm cloud push --name <name> # upload local changes (Team-safe)
"""
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
console.print("Setting up cloud sync with rclone...\n")
@@ -180,35 +226,46 @@ def setup() -> None:
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get tenant info
# --- Resolve target workspace ---
# Trigger: --workspace given. Why: Tigris keys are tenant-scoped, so a
# non-default workspace needs its own bucket + remote. Outcome: scope the
# mount-info/credentials calls and name the remote after the workspace.
if workspace is not None:
target = _resolve_setup_workspace(workspace)
workspace_id: str | None = target.tenant_id
remote_name = remote_name_for_workspace(target.slug, is_default=target.is_default)
console.print(f"[dim]Workspace: {target.name} ({target.slug})[/dim]")
else:
workspace_id = None # default tenant
remote_name = remote_name_for_workspace(None, is_default=True)
# Step 2: Get tenant info (scoped to the target workspace when given)
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = run_with_cleanup(get_mount_info())
tenant_info = run_with_cleanup(get_mount_info(workspace_id=workspace_id))
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
# Step 3: Generate credentials
# Step 3: Generate credentials for that tenant's bucket
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = run_with_cleanup(generate_mount_credentials(tenant_info.tenant_id))
console.print("[green]Generated secure credentials[/green]")
# Step 4: Configure rclone remote
# Step 4: Configure the tenant's rclone remote
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
configure_rclone_remote(
access_key=creds.access_key,
secret_key=creds.secret_key,
remote_name=remote_name,
)
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 --cloud --local-path ~/Documents/research")
console.print("\n Or configure sync for an existing project:")
console.print("1. Configure sync for a project:")
console.print(" bm cloud sync-setup research ~/Documents/research")
console.print("\n2. Preview the initial sync (recommended):")
console.print(" bm project bisync --name research --resync --dry-run")
console.print("\n3. If all looks good, run the actual sync:")
console.print(" bm project bisync --name research --resync")
console.print("\n4. Subsequent syncs (no --resync needed):")
console.print(" bm project bisync --name research")
console.print("\n2. Preview a pull (recommended):")
console.print(" bm cloud pull --name research --dry-run")
console.print("\n3. Fetch cloud changes / upload local changes:")
console.print(" bm cloud pull --name research")
console.print(" bm cloud push --name research")
console.print(
"\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
)
@@ -216,6 +273,8 @@ def setup() -> None:
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
@@ -26,13 +26,23 @@ from basic_memory.cli.commands.cloud.rclone_commands import (
project_sync,
project_transfer,
)
from basic_memory.cli.commands.cloud.rclone_config import (
DEFAULT_RCLONE_REMOTE,
rclone_remote_exists,
remote_name_for_workspace,
)
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.routing import force_routing
from basic_memory.config import BasicMemoryConfig, ConfigManager, ProjectEntry
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import ProjectClient
from basic_memory.mcp.project_context import get_available_workspaces
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.cloud import (
WorkspaceInfo,
format_workspace_choices,
format_workspace_selection_choices,
workspace_matches_exact_identifier,
)
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.utils import generate_permalink, normalize_project_path
@@ -89,12 +99,41 @@ def _require_cloud_credentials(config: BasicMemoryConfig) -> None:
raise typer.Exit(1)
async def _get_workspace_for_project(name: str, config: BasicMemoryConfig) -> WorkspaceInfo:
"""Resolve the cloud workspace targeted by a project-scoped sync command."""
async def _get_workspace_for_project(
name: str,
config: BasicMemoryConfig,
*,
workspace_override: str | None = None,
) -> WorkspaceInfo:
"""Resolve the cloud workspace targeted by a project-scoped sync command.
``workspace_override`` (a slug, name, or tenant_id, e.g. from ``--workspace``)
takes precedence over config, letting the user disambiguate a project name
that exists in more than one workspace.
"""
workspaces = await get_available_workspaces()
if not workspaces:
raise ValueError("No accessible cloud workspaces found for this account")
# An explicit override wins over config — this is how the user disambiguates.
if workspace_override is not None:
matches = [
item
for item in workspaces
if workspace_matches_exact_identifier(item, workspace_override)
]
if len(matches) == 1:
return matches[0]
if not matches:
raise ValueError(
f"No accessible workspace matches '{workspace_override}'.\n"
f"{format_workspace_choices(workspaces)}"
)
raise ValueError(
f"'{workspace_override}' matches multiple workspaces; use a slug or tenant_id:\n"
f"{format_workspace_selection_choices(matches)}"
)
entry = config.projects.get(name)
workspace_id = entry.workspace_id if entry and entry.workspace_id else config.default_workspace
if workspace_id:
@@ -147,9 +186,15 @@ def _require_personal_workspace(
return workspace
async def _get_cloud_project(name: str) -> ProjectItem | None:
"""Fetch a project by name from the cloud API."""
async with get_client(project_name=name) as client:
async def _get_cloud_project(name: str, *, workspace_id: str | None = None) -> ProjectItem | None:
"""Fetch a project by name from the cloud API.
``workspace_id`` routes the lookup to a specific tenant so the project
metadata comes from the same workspace the transfer targets (otherwise
get_client would resolve the workspace from config/default and could read a
different tenant — see #920 review).
"""
async with get_client(project_name=name, workspace=workspace_id) as client:
projects_list = await ProjectClient(client).list_projects()
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
@@ -158,10 +203,17 @@ async def _get_cloud_project(name: str) -> ProjectItem | None:
def _get_sync_project(
name: str, config: BasicMemoryConfig, project_data: ProjectItem
name: str,
config: BasicMemoryConfig,
project_data: ProjectItem,
*,
remote_name: str = DEFAULT_RCLONE_REMOTE,
) -> tuple[SyncProject, str | None]:
"""Build a SyncProject and resolve local_sync_path from config.
``remote_name`` selects which tenant-scoped rclone remote the project routes
through (default tenant vs a team workspace remote).
Returns (sync_project, local_sync_path). Exits if no local_sync_path configured.
"""
sync_entry = config.projects.get(name)
@@ -177,6 +229,7 @@ def _get_sync_project(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
remote_name=remote_name,
)
return sync_project, local_sync_path
@@ -205,7 +258,10 @@ def sync_project_command(
_require_personal_workspace(name, config, unsupported_message=TEAM_WORKSPACE_SYNC_UNSUPPORTED)
try:
# Get tenant info for bucket name
# Get tenant info for bucket name.
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
# because these mirror commands are gated to the (default-tenant) Personal
# workspace, so the default mount info is correct.
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
@@ -259,29 +315,64 @@ def _run_directional_transfer(
on_conflict: ConflictStrategy,
dry_run: bool,
verbose: bool,
workspace: str | None = None,
) -> None:
"""Shared orchestration for `bm cloud push` / `bm cloud pull`.
Detects conflicts first, then aborts (the default) or applies the chosen
resolution. Uses additive `rclone copy`, so it never deletes on the
destination — safe for Team workspaces and therefore not gated.
Routes through the resolved workspace's own tenant-scoped rclone remote, so a
Team project reads/writes the right bucket (see #919).
"""
config = ConfigManager().config
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
tenant_info = run_with_cleanup(get_mount_info())
# --- Resolve the target workspace and its tenant-scoped remote ---
# Tigris credentials are bucket/tenant-scoped, so each workspace has its
# own rclone remote. Resolve which workspace this project belongs to
# (config or --workspace override) before touching any bucket.
try:
target_workspace = run_with_cleanup(
_get_workspace_for_project(name, config, workspace_override=workspace)
)
except Exception as exc:
console.print(f"[red]Error resolving workspace for project '{name}': {exc}[/red]")
raise typer.Exit(1)
remote_name = remote_name_for_workspace(
target_workspace.slug, is_default=target_workspace.is_default
)
# Trigger: the workspace's remote has not been configured yet.
# Why: provisioning mints tenant-scoped credentials and must be explicit
# (no surprise key generation); push/pull only transfer.
# Outcome: stop with the exact setup command for this workspace.
if not rclone_remote_exists(remote_name):
setup_target = (
"" if target_workspace.is_default else f" --workspace {target_workspace.slug}"
)
console.print(f"[red]Workspace '{target_workspace.slug}' is not set up for sync.[/red]")
console.print(f"\nRun: bm cloud setup{setup_target}")
raise typer.Exit(1)
# Get tenant info for bucket name, scoped to the resolved workspace
tenant_info = run_with_cleanup(get_mount_info(workspace_id=target_workspace.tenant_id))
bucket_name = tenant_info.bucket_name
# Get project info
# Get project info from the same workspace we resolved above, so the
# project path and the bucket/remote all refer to one tenant.
with force_routing(cloud=True):
project_data = run_with_cleanup(_get_cloud_project(name))
project_data = run_with_cleanup(
_get_cloud_project(name, workspace_id=target_workspace.tenant_id)
)
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
sync_project, _ = _get_sync_project(name, config, project_data)
sync_project, _ = _get_sync_project(name, config, project_data, remote_name=remote_name)
# --- Detect before transferring ---
plan = project_diff(sync_project, bucket_name, direction)
@@ -355,6 +446,11 @@ def pull_project_command(
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
workspace: str | None = typer.Option(
None,
"--workspace",
help="Workspace (slug, name, or tenant_id) when the project name is ambiguous",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pulling"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
@@ -368,9 +464,10 @@ def pull_project_command(
bm cloud pull --name research
bm cloud pull --name research --dry-run
bm cloud pull --name research --on-conflict keep-cloud
bm cloud pull --name research --workspace acme
"""
_run_directional_transfer(
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
name, "pull", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose, workspace=workspace
)
@@ -382,6 +479,11 @@ def push_project_command(
"--on-conflict",
help="Resolve files that differ on both sides (default: fail and list them)",
),
workspace: str | None = typer.Option(
None,
"--workspace",
help="Workspace (slug, name, or tenant_id) when the project name is ambiguous",
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without pushing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
@@ -396,9 +498,10 @@ def push_project_command(
bm cloud push --name research
bm cloud push --name research --dry-run
bm cloud push --name research --on-conflict keep-local
bm cloud push --name research --workspace acme
"""
_run_directional_transfer(
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose
name, "push", on_conflict=on_conflict, dry_run=dry_run, verbose=verbose, workspace=workspace
)
@@ -421,7 +524,10 @@ def bisync_project_command(
_require_personal_workspace(name, config)
try:
# Get tenant info for bucket name
# Get tenant info for bucket name.
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
# because these mirror commands are gated to the (default-tenant) Personal
# workspace, so the default mount info is correct.
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
@@ -479,7 +585,10 @@ def check_project_command(
_require_cloud_credentials(config)
try:
# Get tenant info for bucket name
# Get tenant info for bucket name.
# TODO(#919): scope to the project's workspace like push/pull. Safe for now
# because these mirror commands are gated to the (default-tenant) Personal
# workspace, so the default mount info is correct.
tenant_info = run_with_cleanup(get_mount_info())
bucket_name = tenant_info.bucket_name
@@ -2,7 +2,8 @@
This module provides simplified, project-scoped rclone operations:
- Each project syncs independently
- Uses single "basic-memory-cloud" remote (not tenant-specific)
- Routes through the project's tenant-scoped remote (SyncProject.remote_name);
the default tenant keeps "basic-memory-cloud", others use their own (see #919)
- Balanced defaults from SPEC-8 Phase 4 testing
- Per-project bisync state tracking
@@ -113,11 +114,15 @@ class SyncProject:
name: Project name
path: Cloud path (e.g., "app/data/research")
local_sync_path: Local directory for syncing (optional)
remote_name: rclone remote serving this project's tenant bucket. Defaults
to the legacy single remote; team/non-default workspaces use their own
(see remote_name_for_workspace).
"""
name: str
path: str
local_sync_path: Optional[str] = None
remote_name: str = "basic-memory-cloud"
def get_bmignore_filter_path() -> Path:
@@ -179,10 +184,13 @@ def get_project_remote(project: SyncProject, bucket_name: str) -> str:
The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
is mounted at /app/data on the fly machine. We need to strip the /app/data/
prefix to get the actual S3 path within the bucket.
The remote name comes from the project so non-default/team workspaces route
through their own tenant-scoped remote (see #919).
"""
# Normalize path to strip /app/data/ mount point prefix
cloud_path = normalize_project_path(project.path).lstrip("/")
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
return f"{project.remote_name}:{bucket_name}/{cloud_path}"
# --- Directional transfer primitives (push / pull) ---
@@ -1,11 +1,14 @@
"""rclone configuration management for Basic Memory Cloud.
This module provides simplified rclone configuration for SPEC-20.
Uses a single "basic-memory-cloud" remote for all operations.
This module owns rclone remote configuration and naming. The default tenant uses
the "basic-memory-cloud" remote (from SPEC-20); non-default/team workspaces each
get their own tenant-scoped remote via remote_name_for_workspace (see #919),
since Tigris credentials are bucket-scoped.
"""
import configparser
import os
import re
import shutil
from pathlib import Path
from typing import Optional
@@ -61,25 +64,65 @@ def save_rclone_config(config: configparser.ConfigParser) -> None:
console.print(f"[dim]Updated rclone config: {config_path}[/dim]")
# The default remote serves the account's default tenant (back-compat with SPEC-20,
# which used a single "basic-memory-cloud" remote). Non-default workspaces each get
# their own remote, since Tigris credentials are bucket/tenant-scoped (see #919).
DEFAULT_RCLONE_REMOTE = "basic-memory-cloud"
# rclone remote section names allow letters, digits, hyphens, and underscores.
# The slug comes from the cloud API (a trust boundary), so validate it before
# splicing it into a remote name to avoid a broken/unusable rclone.conf section.
_SAFE_SLUG = re.compile(r"^[A-Za-z0-9_-]+$")
def remote_name_for_workspace(slug: str | None, *, is_default: bool) -> str:
"""Return the rclone remote name for a workspace.
The default workspace keeps the legacy ``basic-memory-cloud`` remote so
existing setups keep working; other workspaces get ``basic-memory-cloud-<slug>``.
Raises:
RcloneConfigError: If a non-default workspace slug contains characters
that are not valid in an rclone remote name.
"""
if is_default or not slug:
return DEFAULT_RCLONE_REMOTE
if not _SAFE_SLUG.match(slug):
raise RcloneConfigError(
f"Workspace slug '{slug}' cannot be used as an rclone remote name "
"(allowed: letters, digits, hyphens, underscores)."
)
return f"{DEFAULT_RCLONE_REMOTE}-{slug}"
def rclone_remote_exists(remote_name: str) -> bool:
"""Return whether an rclone remote section is already configured."""
return load_rclone_config().has_section(remote_name)
def configure_rclone_remote(
access_key: str,
secret_key: str,
endpoint: str = "https://fly.storage.tigris.dev",
region: str = "auto",
remote_name: str = DEFAULT_RCLONE_REMOTE,
) -> str:
"""Configure single rclone remote named 'basic-memory-cloud'.
"""Configure an rclone remote for one tenant's bucket.
This is the simplified approach from SPEC-20 that uses one remote
for all Basic Memory cloud operations (not tenant-specific).
Each tenant (personal or team) has its own bucket-scoped credentials, so a
remote maps 1:1 to a tenant. The default tenant keeps ``basic-memory-cloud``;
other workspaces pass ``remote_name`` from :func:`remote_name_for_workspace`.
Args:
access_key: S3 access key ID
secret_key: S3 secret access key
endpoint: S3-compatible endpoint URL
region: S3 region (default: auto)
remote_name: rclone remote section name to write
Returns:
The remote name: "basic-memory-cloud"
The remote name that was configured
"""
# Backup existing config
backup_rclone_config()
@@ -87,24 +130,21 @@ def configure_rclone_remote(
# Load existing config
config = load_rclone_config()
# Single remote name (not tenant-specific)
REMOTE_NAME = "basic-memory-cloud"
# Add/update the remote section
if not config.has_section(REMOTE_NAME):
config.add_section(REMOTE_NAME)
if not config.has_section(remote_name):
config.add_section(remote_name)
config.set(REMOTE_NAME, "type", "s3")
config.set(REMOTE_NAME, "provider", "Other")
config.set(REMOTE_NAME, "access_key_id", access_key)
config.set(REMOTE_NAME, "secret_access_key", secret_key)
config.set(REMOTE_NAME, "endpoint", endpoint)
config.set(REMOTE_NAME, "region", region)
config.set(remote_name, "type", "s3")
config.set(remote_name, "provider", "Other")
config.set(remote_name, "access_key_id", access_key)
config.set(remote_name, "secret_access_key", secret_key)
config.set(remote_name, "endpoint", endpoint)
config.set(remote_name, "region", region)
# Prevent unnecessary encoding of filenames (only encode slashes and invalid UTF-8)
# This prevents files with spaces like "Hello World.md" from being quoted
config.set(REMOTE_NAME, "encoding", "Slash,InvalidUtf8")
config.set(remote_name, "encoding", "Slash,InvalidUtf8")
# Save updated config
save_rclone_config(config)
console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
return REMOTE_NAME
console.print(f"[green]Configured rclone remote: {remote_name}[/green]")
return remote_name