feat: Proton Drive Hermes skill — rclone-backed file operations

Build the proton-drive Hermes skill following the Phase 4 spec
from ARCHITECTURE.md (§5). Primary path: rclone protondrive backend
with Drive SDK as a fallback option.

Skill components:
  - skills/proton-drive/SKILL.md — YAML frontmatter + full docs for
    all 9 tools (list, read, download, upload, search, mkdir,
    delete, stat, sync) with usage, error handling, security notes
  - skills/proton-drive/__init__.py — package init with exports
  - skills/proton-drive/tools.py — Python subprocess wrappers for
    each tool, plus rclone availability/remote checks
  - tests/test_drive.py — 25 unit tests (all pass) with mocked
    subprocess.run

All 9 Proton Drive tools implemented:
  proton_drive_list, proton_drive_read, proton_drive_download,
  proton_drive_upload, proton_drive_search, proton_drive_mkdir,
  proton_drive_delete, proton_drive_stat, proton_drive_sync

Signed-off-by: Bee <bee@trentuna.com>
This commit is contained in:
exe.dev user 2026-06-08 18:30:26 +02:00
parent c332322220
commit f103d5f44f
No known key found for this signature in database
5 changed files with 1223 additions and 14 deletions

View file

@ -0,0 +1,268 @@
---
name: proton-drive
description: Hermes skill for Proton Drive — list, read, upload, download, search, and manage files using the rclone protondrive backend.
version: 1.0.0
author: Trentuna / Bee
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [proton, drive, cloud-storage, rclone, file-management]
related_skills: [proton-mail, proton-pass, proton-vpn]
required_environment_variables:
- PROTON_RCLONE_REMOTE
optional_environment_variables:
- PROTON_RCLONE_PATH
- PROTON_DRIVE_USE_SDK
---
# Proton Drive Skill
> Agents can read, write, search, and manage files on Proton Drive through
> the battle-tested [rclone protondrive backend](https://rclone.org/protondrive/).
## Prerequisites
### 1. Install rclone
```bash
# Linux (official script)
curl https://rclone.org/install.sh | sudo bash
# macOS
brew install rclone
# Verify
rclone version
```
### 2. Configure the protondrive remote
```bash
rclone config create protondrive protondrive \
username=your@proton.me \
password=your_password \
--non-interactive
```
Or interactively:
```bash
rclone config
# → Choose "n" (new remote)
# → Name: protondrive
# → Type: protondrive
# → Follow prompts for username, password, 2FA
```
### 3. Set environment variables (optional)
```bash
export PROTON_RCLONE_REMOTE=protondrive # default: protondrive
export PROTON_RCLONE_PATH=/path/to/rclone # default: auto-detect from PATH
```
On first run, rclone will prompt for the mailbox password (your Proton
login password). After authentication, tokens are cached locally by
rclone's own credential store — no additional auth management needed
for subsequent calls.
## Tools
The skill provides 9 tools for interacting with Proton Drive. Each tool
shells out to `rclone` with the configured protondrive remote.
### `proton_drive_list`
List files and folders in a path on Proton Drive.
```
Usage: rclone lsf --json <remote>:<path>
Args:
path (string, default: "/") — Path to list (e.g. "Documents" or "/")
recursive (bool, default: false) — List recursively
dirs_only (bool, default: false) — Show directories only
files_only (bool, default: false) — Show files only
Returns: JSON array of {Name, Path, Size, ModTime, IsDir} items
```
### `proton_drive_read`
Read a file's content from Proton Drive.
```
Usage: rclone cat <remote>:<path>
Args:
path (string, required) — Path to the file (e.g. "Documents/notes.txt")
head (int, optional) — Only read first N lines
tail (int, optional) — Only read last N lines
Returns: File content as text string (up to 10MB; larger files use download instead)
```
Large files (>10MB) should be downloaded rather than read inline. The
tool will return an error suggesting `proton_drive_download` for files
exceeding the size threshold.
### `proton_drive_download`
Download a file from Proton Drive to a local path.
```
Usage: rclone copy <remote>:<path> <local_path>
Args:
remote_path (string, required) — Source path on Drive (e.g. "Documents/report.pdf")
local_path (string, required) — Destination local path (absolute or ~/expanded)
progress (bool, default: false) — Show transfer progress
Returns: JSON with {status, local_path, size_bytes}
```
The local path must be writable. If the path ends with `/`, the file
is downloaded into that directory preserving its filename.
### `proton_drive_upload`
Upload a local file or directory to Proton Drive.
```
Usage: rclone copy <local_path> <remote>:<path>
Args:
local_path (string, required) — Source path on local filesystem
remote_path (string, required) — Destination path on Drive (e.g. "Documents/")
create_parents (bool, default: true) — Create parent dirs if they don't exist
progress (bool, default: false) — Show transfer progress
Returns: JSON with {status, remote_path, size_bytes}
```
Supports both individual files and entire directories.
### `proton_drive_search`
Search for files by name across Proton Drive.
```
Usage: rclone lsf -R --files-only <remote>: | grep -i <query>
OR (for structured results): rclone lsf -R --json <remote>: | jq <filter>
Args:
query (string, required) — Search term or regex pattern
path (string, default: "/") — Root path to search under
regex (bool, default: false) — Treat query as regex instead of substring
max_results (int, default: 50) — Max results to return
Returns: JSON array of matching {Name, Path, Size, ModTime}
```
### `proton_drive_mkdir`
Create a folder on Proton Drive.
```
Usage: rclone mkdir <remote>:<path>
Args:
path (string, required) — Path of folder to create (e.g. "Documents/NewFolder")
Returns: JSON with {status: "created", path}
```
Creates all parent directories if they don't exist (like `mkdir -p`).
### `proton_drive_delete`
Delete a file or empty folder from Proton Drive.
```
Usage: rclone delete <remote>:<path>
Args:
path (string, required) — Path to the file/folder to delete
recursive (bool, default: false) — Recursively delete folder contents
Returns: JSON with {status: "deleted", path}
```
Non-recursive deletion of non-empty folders will fail. rclone's
`purge` command is used for recursive deletes.
### `proton_drive_stat`
Get detailed metadata for a file or folder.
```
Usage: rclone lsl <remote>:<path>
OR (detailed): rclone lsf --json <remote>:<path>
Args:
path (string, required) — Path to the file or folder
Returns: JSON with {Name, Path, Size, ModTime, IsDir, Hash, MimeType}
```
### `proton_drive_sync`
Synchronize a local directory with Proton Drive (or vice versa).
```
Usage: rclone sync <source> <destination> [flags]
Args:
source (string, required) — Source path (local: or remote:)
dest (string, required) — Destination (local: or remote:)
direction (enum, default: "upload") — "upload" (local→Drive), "download" (Drive→local), "bidirectional"
dry_run (bool, default: true) — If true, show what would change without syncing
delete_excluded (bool, default: false) — Delete files at dest not present at source
Returns: JSON with {status, changed, added, deleted, errors}
```
**IMPORTANT:** Defaults to `dry_run=true` for safety. The agent MUST
confirm with the user before running a live sync, especially when
`delete_excluded=true` — this can cause data loss.
## Implementation
All tools are implemented as Python subprocess wrappers in the
`tools/` subdirectory. The primary backend is `rclone`; the
TypeScript Drive SDK is available as a fallback via
`PROTON_DRIVE_USE_SDK=true`.
### Tool Handler Pattern
```python
def handle_proton_drive_list(args: dict, **kwargs) -> dict:
path = args.get("path", "/")
recursive = args.get("recursive", False)
cmd = _build_rclone_command("lsf", "--json", path, recursive=recursive)
result = _run_rclone(cmd, timeout=30)
if result.returncode != 0:
return {"error": result.stderr.strip()}
items = [json.loads(line) for line in result.stdout.strip().split("\n") if line]
return {"items": items}
```
### Rclone Configuration Check
Before any tool execution, verify the rclone remote exists:
```python
def _check_rclone_remote(remote: str) -> bool:
result = subprocess.run(
["rclone", "listremotes"],
capture_output=True, text=True, timeout=5
)
return f"{remote}:" in result.stdout
```
If the remote is not configured, return a clear error with setup
instructions referencing the README.
## Error Handling
| Error | Cause | Resolution |
|-------|-------|------------|
| `remote not found` | protondrive remote not configured | Run `rclone config` or refer to README |
| `authentication required` | Token expired or invalid | Run `rclone config reconnect <remote>` |
| `file not found` | Path doesn't exist | Check path with `proton_drive_list` |
| `file too large` | Read attempt on >10MB file | Use `proton_drive_download` instead |
| `rate limited` | Too many API calls | Retry with backoff (rclone does this auto) |
## Security Notes
- Files read by `proton_drive_read` enter the agent's context. For
sensitive documents, prefer `proton_drive_download` and specify a
local path.
- `proton_drive_sync` with `delete_excluded=true` is destructive.
Always default to `dry_run=true` and require confirmation.
- rclone stores tokens in its config file
(`~/.config/rclone/rclone.conf`). Ensure this file has appropriate
file permissions (`chmod 600`).

View file

@ -0,0 +1,31 @@
"""Proton Drive skill — rclone-backed file operations for Hermes agents."""
from .tools import (
check_rclone_availability,
check_rclone_remote,
get_rclone_remote,
proton_drive_delete,
proton_drive_download,
proton_drive_list,
proton_drive_mkdir,
proton_drive_read,
proton_drive_search,
proton_drive_stat,
proton_drive_sync,
proton_drive_upload,
)
__all__ = [
"check_rclone_availability",
"check_rclone_remote",
"get_rclone_remote",
"proton_drive_delete",
"proton_drive_download",
"proton_drive_list",
"proton_drive_mkdir",
"proton_drive_read",
"proton_drive_search",
"proton_drive_stat",
"proton_drive_sync",
"proton_drive_upload",
]

View file

@ -0,0 +1,641 @@
"""Proton Drive tool implementations — subprocess wrappers around rclone protondrive.
All tools shell out to the rclone binary with a configured protondrive remote.
rclone handles all encryption, chunking, and API semantics transparently.
"""
import json
import os
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_REMOTE = "protondrive"
RCLONE_MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MB
RCLONE_TIMEOUT_LIST = 30
RCLONE_TIMEOUT_IO = 60
RCLONE_TIMEOUT_SYNC = 300
def _get_rclone_path() -> str:
"""Return the rclone binary path, checking env override first."""
return os.environ.get("PROTON_RCLONE_PATH", "rclone")
def _get_remote() -> str:
"""Return the configured protondrive remote name."""
return os.environ.get("PROTON_RCLONE_REMOTE", DEFAULT_REMOTE)
def _remote_path(path: str) -> str:
"""Join remote name and path into a single rclone argument."""
remote = _get_remote()
# Strip leading slash for path joining; rclone expects "remote:path"
clean = path.lstrip("/")
return f"{remote}:{clean}"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_rclone(
args: list[str],
timeout: int = RCLONE_TIMEOUT_LIST,
stdin: Optional[bytes] = None,
) -> subprocess.CompletedProcess:
"""Run rclone with the given args and return the CompletedProcess."""
rclone = _get_rclone_path()
cmd = [rclone] + args
try:
return subprocess.run(
cmd,
input=stdin,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=cmd,
returncode=-1,
stdout="",
stderr="rclone timed out after {timeout}s",
)
except FileNotFoundError:
return subprocess.CompletedProcess(
args=cmd,
returncode=-2,
stdout="",
stderr=f"rclone not found at '{rclone}'. Install: https://rclone.org/install",
)
def _parse_lsf_json(output: str) -> list[dict]:
"""Parse newline-delimited JSON output from `rclone lsf --json`."""
items = []
for line in output.strip().split("\n"):
line = line.strip()
if line:
try:
items.append(json.loads(line))
except json.JSONDecodeError:
items.append({"Name": line, "raw": True})
return items
def _parse_lsl_output(output: str) -> list[dict]:
"""Parse tabular output from `rclone lsl`."""
items = []
for line in output.strip().split("\n"):
line = line.strip()
if not line:
continue
# rclone lsl format: <size> <modtime> <path>
parts = line.split(maxsplit=2)
if len(parts) >= 3:
items.append({
"Size": parts[0],
"ModTime": parts[1],
"Path": parts[2],
})
elif len(parts) >= 1:
items.append({"Size": parts[0]})
return items
def _check_rclone_available() -> str | None:
"""Check if rclone binary is reachable. Returns None if OK, error string if not."""
rclone = _get_rclone_path()
try:
result = subprocess.run(
[rclone, "version"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return f"rclone execution failed: {result.stderr.strip()}"
return None
except FileNotFoundError:
return f"rclone not found at '{rclone}'. Install from https://rclone.org/install"
except subprocess.TimeoutExpired:
return "rclone version check timed out"
def _check_remote_exists(remote: str) -> str | None:
"""Check if the named remote is configured. Returns None if OK, error string if not."""
result = _run_rclone(["listremotes"], timeout=5)
if f"{remote}:" in result.stdout:
return None
return (
f"rclone remote '{remote}' not configured. "
f"Run: rclone config create {remote} protondrive username=your@proton.me"
)
# ---------------------------------------------------------------------------
# Public API / Tool Handlers
# ---------------------------------------------------------------------------
def check_rclone_availability() -> dict:
"""Verify rclone is installed and the protondrive remote is configured.
Returns:
dict with status summary and any errors found
"""
errors = []
rclone_err = _check_rclone_available()
if rclone_err:
return {"available": False, "errors": [rclone_err]}
remote = _get_remote()
remote_err = _check_remote_exists(remote)
if remote_err:
errors.append(remote_err)
return {
"available": len(errors) == 0,
"rclone": "ok",
"remote": remote,
"remote_configured": remote_err is None,
"errors": errors or None,
}
def check_rclone_remote() -> dict:
"""Check rclone remote configuration status. Legacy alias for environment checks."""
remote = _get_remote()
err = _check_remote_exists(remote)
return {
"remote": remote,
"configured": err is None,
"error": err,
}
def get_rclone_remote() -> str:
"""Return the currently configured rclone remote name."""
return _get_remote()
# ---------------------------------------------------------------------------
# proton_drive_list
# ---------------------------------------------------------------------------
def proton_drive_list(
path: str = "/",
recursive: bool = False,
dirs_only: bool = False,
files_only: bool = False,
) -> dict:
"""List files and folders at the given path on Proton Drive.
Args:
path: Directory path on Proton Drive (default: root "/")
recursive: List all subdirectories recursively
dirs_only: Show directories only
files_only: Show files only
Returns:
dict with items array or error
"""
args = ["lsf", "--json"]
if recursive:
args.append("-R")
if dirs_only:
args.append("--dirs-only")
if files_only:
args.append("--files-only")
rp = _remote_path(path)
args.append(rp)
result = _run_rclone(args, timeout=RCLONE_TIMEOUT_LIST)
if result.returncode != 0:
return {"error": result.stderr.strip()}
items = _parse_lsf_json(result.stdout)
return {"items": items, "count": len(items), "path": path}
# ---------------------------------------------------------------------------
# proton_drive_read
# ---------------------------------------------------------------------------
def proton_drive_read(
path: str,
head: Optional[int] = None,
tail: Optional[int] = None,
) -> dict:
"""Read the contents of a text file from Proton Drive.
For files >10MB, returns an error suggesting proton_drive_download instead.
Args:
path: Path to the file on Proton Drive (e.g. "Documents/notes.txt")
head: If set, read only the first N lines
tail: If set, read only the last N lines
Returns:
dict with content or error
"""
# Check file size first via stat
stat_result = proton_drive_stat(path)
if "error" in stat_result:
return stat_result
# stat_result may have "Size" (from lsl) or "size" (from lsf --json)
raw_size = stat_result.get("Size") or stat_result.get("size") or 0
try:
size_int = int(raw_size)
except (ValueError, TypeError):
size_int = 0
if size_int > RCLONE_MAX_READ_SIZE:
return {
"error": (
f"File is {size_int} bytes, exceeding {RCLONE_MAX_READ_SIZE} byte "
f"inline read limit. Use proton_drive_download instead."
),
"size_bytes": size_int,
}
rp = _remote_path(path)
result = _run_rclone(["cat", rp], timeout=RCLONE_TIMEOUT_IO)
if result.returncode != 0:
return {"error": result.stderr.strip()}
content = result.stdout
if head is not None:
lines = content.split("\n")
content = "\n".join(lines[:head])
elif tail is not None:
lines = content.split("\n")
content = "\n".join(lines[-tail:])
return {
"content": content,
"size_bytes": len(content.encode("utf-8")),
"path": path,
}
# ---------------------------------------------------------------------------
# proton_drive_download
# ---------------------------------------------------------------------------
def proton_drive_download(
remote_path: str,
local_path: str,
progress: bool = False,
) -> dict:
"""Download a file from Proton Drive to the local filesystem.
Args:
remote_path: Source path on Proton Drive
local_path: Destination path on local filesystem (absolute or ~/expanded)
progress: Show rclone transfer progress output
Returns:
dict with status, local path, and size
"""
expanded_local = os.path.expanduser(local_path)
rp = _remote_path(remote_path)
# If local_path ends with /, treat as directory
if local_path.endswith("/") or local_path.endswith(os.sep):
os.makedirs(expanded_local, exist_ok=True)
dest = expanded_local
else:
# Ensure parent dir exists
parent = os.path.dirname(expanded_local)
if parent:
os.makedirs(parent, exist_ok=True)
dest = expanded_local
args = ["copy"]
if progress:
args.append("--progress")
args.extend([rp, dest])
result = _run_rclone(args, timeout=RCLONE_TIMEOUT_IO)
if result.returncode != 0:
return {"error": result.stderr.strip()}
# Determine the actual file that was written
if os.path.isdir(dest):
filename = os.path.basename(remote_path.rstrip("/"))
actual_path = os.path.join(dest, filename)
else:
actual_path = dest
size = 0
if os.path.isfile(actual_path):
size = os.path.getsize(actual_path)
return {
"status": "downloaded",
"local_path": actual_path,
"size_bytes": size,
"remote_path": remote_path,
}
# ---------------------------------------------------------------------------
# proton_drive_upload
# ---------------------------------------------------------------------------
def proton_drive_upload(
local_path: str,
remote_path: str,
create_parents: bool = True,
progress: bool = False,
) -> dict:
"""Upload a file or directory from the local filesystem to Proton Drive.
Args:
local_path: Source path on local filesystem
remote_path: Destination path on Proton Drive
create_parents: Create parent dirs if they don't exist (default: True)
progress: Show rclone transfer progress output
Returns:
dict with status, remote path, and size
"""
expanded_local = os.path.expanduser(local_path)
if not os.path.exists(expanded_local):
return {"error": f"Local path '{expanded_local}' does not exist"}
if create_parents:
# Ensure the remote parent path exists by creating it
parent_remote = remote_path.rstrip("/")
# If remote_path looks like a file path, get its parent dir
if not remote_path.endswith("/"):
parent_dir = os.path.dirname(remote_path) or "/"
else:
parent_dir = remote_path
mkdir_rp = _remote_path(parent_dir)
_run_rclone(["mkdir", mkdir_rp], timeout=RCLONE_TIMEOUT_LIST)
rp = _remote_path(remote_path)
args = ["copy"]
if progress:
args.append("--progress")
args.extend([expanded_local, rp])
result = _run_rclone(args, timeout=RCLONE_TIMEOUT_IO)
if result.returncode != 0:
return {"error": result.stderr.strip()}
file_size = 0
if os.path.isfile(expanded_local):
file_size = os.path.getsize(expanded_local)
return {
"status": "uploaded",
"remote_path": remote_path,
"local_path": expanded_local,
"size_bytes": file_size,
}
# ---------------------------------------------------------------------------
# proton_drive_search
# ---------------------------------------------------------------------------
def proton_drive_search(
query: str,
path: str = "/",
regex: bool = False,
max_results: int = 50,
) -> dict:
"""Search for files by name across Proton Drive.
Uses `rclone lsf -R --files-only --json` piped through a name filter.
Args:
query: Search term (substring) or regex pattern
path: Root path to search under (default: "/")
regex: If True, treat query as regex instead of substring match
max_results: Maximum number of results to return
Returns:
dict with matching items array
"""
rp = _remote_path(path)
result = _run_rclone(
["lsf", "-R", "--files-only", "--json", rp],
timeout=RCLONE_TIMEOUT_LIST,
)
if result.returncode != 0:
return {"error": result.stderr.strip()}
all_items = _parse_lsf_json(result.stdout)
if regex:
try:
pattern = re.compile(query, re.IGNORECASE)
except re.error as e:
return {"error": f"Invalid regex: {e}"}
matches = [it for it in all_items if pattern.search(it.get("Name", ""))]
else:
q = query.lower()
matches = [it for it in all_items if q in it.get("Name", "").lower()]
limited = matches[:max_results]
return {
"items": limited,
"count": len(limited),
"total_matches": len(matches),
"query": query,
"path": path,
}
# ---------------------------------------------------------------------------
# proton_drive_mkdir
# ---------------------------------------------------------------------------
def proton_drive_mkdir(path: str) -> dict:
"""Create a folder on Proton Drive.
Creates all parent directories if they don't exist (like mkdir -p).
Args:
path: Path of folder to create (e.g. "Documents/NewFolder")
Returns:
dict with status
"""
rp = _remote_path(path)
result = _run_rclone(["mkdir", rp], timeout=RCLONE_TIMEOUT_LIST)
if result.returncode != 0:
return {"error": result.stderr.strip()}
return {"status": "created", "path": path}
# ---------------------------------------------------------------------------
# proton_drive_delete
# ---------------------------------------------------------------------------
def proton_drive_delete(path: str, recursive: bool = False) -> dict:
"""Delete a file or folder from Proton Drive.
Non-recursive deletion of non-empty folders will fail.
Args:
path: Path to the file or folder to delete
recursive: Recursively delete folder contents
Returns:
dict with status
"""
rp = _remote_path(path)
if recursive:
# rclone purge removes a directory and all its contents
result = _run_rclone(["purge", rp], timeout=RCLONE_TIMEOUT_IO)
else:
# rclone delete removes files only (fails on non-empty dirs)
result = _run_rclone(["delete", rp], timeout=RCLONE_TIMEOUT_IO)
if result.returncode != 0:
return {"error": result.stderr.strip()}
return {"status": "deleted", "path": path, "recursive": recursive}
# ---------------------------------------------------------------------------
# proton_drive_stat
# ---------------------------------------------------------------------------
def proton_drive_stat(path: str) -> dict:
"""Get detailed metadata for a file or folder.
Args:
path: Path to the file or folder on Proton Drive
Returns:
dict with metadata (Name, Path, Size, ModTime, IsDir, etc.)
"""
# Use lsl for size + modtime, then lsf --json for structured info
rp = _remote_path(path)
lsl_result = _run_rclone(["lsl", rp], timeout=RCLONE_TIMEOUT_LIST)
if lsl_result.returncode != 0:
# Try lsf instead (might be a directory)
lsf_result = _run_rclone(
["lsf", "--json", rp], timeout=RCLONE_TIMEOUT_LIST
)
if lsf_result.returncode != 0:
return {"error": lsf_result.stderr.strip()}
items = _parse_lsf_json(lsf_result.stdout)
if items:
return items[0]
return {"path": path, "IsDir": True}
parsed = _parse_lsl_output(lsl_result.stdout)
if parsed:
info = parsed[0]
info["path"] = path
info["IsDir"] = False
return info
return {"path": path, "error": "no metadata returned"}
# ---------------------------------------------------------------------------
# proton_drive_sync
# ---------------------------------------------------------------------------
def proton_drive_sync(
source: str,
dest: str,
dry_run: bool = True,
delete_excluded: bool = False,
) -> dict:
"""Synchronize a local directory with Proton Drive.
WARNING: Defaults to dry_run=True. Must confirm before live sync,
especially with delete_excluded=True.
Args:
source: Source path (local:/path or remote:path)
dest: Destination path (local:/path or remote:path)
dry_run: If True, show what would change without applying (default: True)
delete_excluded: Delete files at dest not present at source
Returns:
dict with sync report
"""
args = ["sync"]
if dry_run:
args.append("--dry-run")
if delete_excluded:
args.append("--delete-excluded")
# Auto-expand ~ in local paths
src = source if ":" in source else os.path.expanduser(source)
dst = dest if ":" in dest else os.path.expanduser(dest)
args.extend([src, dst])
result = _run_rclone(args, timeout=RCLONE_TIMEOUT_SYNC)
if result.returncode != 0:
return {"error": result.stderr.strip()}
# Parse output for summary information
summary = _parse_sync_output(result.stdout, result.stderr)
return {
"status": "dry_run" if dry_run else "synced",
"source": source,
"dest": dest,
"dry_run": dry_run,
"delete_excluded": delete_excluded,
**summary,
}
def _parse_sync_output(stdout: str, stderr: str) -> dict:
"""Extract transfer summary from rclone sync output."""
changes = []
errors = []
for line in stdout.split("\n"):
line = line.strip()
if line:
changes.append(line)
# rclone reports errors on stderr
for line in stderr.split("\n"):
line = line.strip()
if line and ("ERROR" in line.upper() or "failed" in line.lower()):
errors.append(line)
return {
"changes_log": changes,
"errors": errors or None,
"change_count": len(changes),
"error_count": len(errors),
}