From d1196750c0cc7a896affdf7743a7888a35d199e9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 12:07:29 -0600 Subject: [PATCH] feat(profiles): REST export/import + extra_files overlay hook export_profile() accepts extra_files (root-relative filename -> text) so a caller can stage companion files into the archive; the desktop uses it for desktop.json, its appearance/interface overlay, now part of the default profile's export allow-list. New routes wrapping the existing hermes profile export/import machinery: - POST /api/profiles/{name}/export (extra_files + optional output path) - POST /api/profiles/import (returns the bundled desktop overlay) - GET /api/profiles/{name}/desktop-overlay Paths cross the API, not bytes - the desktop's native dialogs and its local/pooled backends share a filesystem. --- hermes_cli/profiles.py | 19 +++++- hermes_cli/web_models.py | 16 +++++ hermes_cli/web_routers/profiles.py | 104 +++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 4ed717668aabb..10aa296d917c0 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -30,7 +30,7 @@ import sys import time from dataclasses import dataclass from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from agent.skill_utils import is_excluded_skill_path @@ -237,6 +237,9 @@ _DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({ # Configuration / persona "config.yaml", "SOUL.md", "MEMORY.md", "USER.md", "todo.json", "system_prompt.md", "AGENTS.md", "CLAUDE.md", ".cursorrules", + # Desktop appearance/interface overlay (written by the desktop app's + # profile export; applied by its import — see desktop.json handling). + "desktop.json", # User-facing skill, cron, and session artifacts "skills", "cron", "scripts", "sessions", # Plugin / memory surfaces (per-profile overrides live here) @@ -1898,9 +1901,12 @@ def _default_export_ignore(root_dir: Path): return _ignore -def export_profile(name: str, output_path: str) -> Path: +def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path: """Export a profile to a tar.gz archive. + ``extra_files`` maps root-relative filenames (e.g. ``desktop.json``) to + text content staged into the archive alongside the profile's own files — + the desktop app uses it to bundle its appearance/interface overlay. Returns the output file path. """ import tempfile @@ -1915,6 +1921,13 @@ def export_profile(name: str, output_path: str) -> Path: # shutil.make_archive wants the base name without extension base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + def _stage_extras(staged: Path) -> None: + for rel, content in (extra_files or {}).items(): + parts = _normalize_profile_archive_parts(rel) + target = staged.joinpath(*parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + if canon == "default": # The default profile IS ~/.hermes itself — its parent is ~/ and its # directory name is ".hermes", not "default". We stage a clean copy @@ -1927,6 +1940,7 @@ def export_profile(name: str, output_path: str) -> Path: symlinks=True, ignore=_default_export_ignore(profile_dir), ) + _stage_extras(staged) result = shutil.make_archive(base, "gztar", tmpdir, "default") return Path(result) @@ -1940,6 +1954,7 @@ def export_profile(name: str, output_path: str) -> Path: symlinks=True, ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents), ) + _stage_extras(staged) result = shutil.make_archive(base, "gztar", tmpdir, canon) return Path(result) diff --git a/hermes_cli/web_models.py b/hermes_cli/web_models.py index 3ff438c63800f..03dd3a97365b6 100644 --- a/hermes_cli/web_models.py +++ b/hermes_cli/web_models.py @@ -578,6 +578,22 @@ class ProfileRename(BaseModel): new_name: str +class ProfileExport(BaseModel): + # Optional extra root-level files to stage into the archive, filename → + # text content (e.g. desktop.json — the desktop appearance overlay). + extra_files: Dict[str, str] = {} + # Where to write the archive. Empty → a staging path under HERMES_HOME. + output: str = "" + + +class ProfileImport(BaseModel): + # Path to a profile .tar.gz on the backend's filesystem (the desktop's + # local/pooled backends share the machine with the picker dialog). + archive: str + # Override the profile name inferred from the archive root. + name: Optional[str] = None + + class ProfileSoulUpdate(BaseModel): content: str diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index d7bc45c2d8130..b3587ccd9eb1f 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -26,6 +26,8 @@ from hermes_cli.web_deps import late from hermes_cli.web_models import ( ProfileCreate, ProfileActiveUpdate, + ProfileExport, + ProfileImport, ProfileRename, ProfileSoulUpdate, ProfileDescriptionUpdate, @@ -687,3 +689,105 @@ async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): # auto-generated. "description_auto": bool(outcome.ok), } + + +# ── Export / Import ────────────────────────────────────────────────────────── +# Profile sharing for the desktop: wraps hermes_cli.profiles.export_profile / +# import_profile (the same machinery behind `hermes profile export|import`). +# Paths are exchanged, not bytes — the desktop's local and pooled backends +# share the filesystem with the native save/open dialogs that produce them. + + +@router.post("/api/profiles/{name}/export") +async def export_profile_endpoint(name: str, body: ProfileExport): + from hermes_cli import profiles as profiles_mod + + output = (body.output or "").strip() + if not output: + from hermes_constants import get_hermes_home + staging = get_hermes_home() / "profile-exports" + try: + staging.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not create export directory: {exc}") + stamp = time.strftime("%Y%m%d-%H%M%S") + output = str(staging / f"{profiles_mod.normalize_profile_name(name)}-{stamp}.tar.gz") + + loop = asyncio.get_running_loop() + try: + result = await loop.run_in_executor( + None, + lambda: profiles_mod.export_profile(name, output, extra_files=body.extra_files or None), + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/%s/export failed", name) + raise HTTPException(status_code=500, detail=str(e)) + return {"ok": True, "archive": str(result)} + + +@router.post("/api/profiles/import") +async def import_profile_endpoint(body: ProfileImport): + from hermes_cli import profiles as profiles_mod + + archive = (body.archive or "").strip() + if not archive: + raise HTTPException(status_code=400, detail="archive path is required") + + loop = asyncio.get_running_loop() + try: + profile_dir = await loop.run_in_executor( + None, + lambda: profiles_mod.import_profile(archive, name=(body.name or "").strip() or None), + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ValueError, FileExistsError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + _log.exception("POST /api/profiles/import failed") + raise HTTPException(status_code=500, detail=str(e)) + + imported = profile_dir.name + # Match the CLI import flow: create the wrapper alias when it's safe. + try: + if not profiles_mod.check_alias_collision(imported): + profiles_mod.create_wrapper_script(imported) + except Exception: + _log.exception("Creating wrapper for imported profile %s failed", imported) + + # Surface the bundled desktop appearance overlay (if the archive carried + # one) so the desktop can apply theme/interface prefs without re-reading + # the file over another round-trip. + desktop_overlay = None + overlay_path = profile_dir / "desktop.json" + if overlay_path.is_file(): + try: + import json as _json + desktop_overlay = _json.loads(overlay_path.read_text(encoding="utf-8")) + except Exception: + _log.exception("Reading desktop.json from imported profile %s failed", imported) + + return { + "ok": True, + "name": imported, + "path": str(profile_dir), + "desktop": desktop_overlay, + } + + +@router.get("/api/profiles/{name}/desktop-overlay") +async def get_profile_desktop_overlay(name: str): + """The desktop appearance/interface overlay bundled with an imported + profile (``desktop.json`` at the profile root), or ``exists: false``.""" + overlay_path = _resolve_profile_dir(name) / "desktop.json" + if not overlay_path.is_file(): + return {"exists": False, "desktop": None} + try: + import json as _json + return {"exists": True, "desktop": _json.loads(overlay_path.read_text(encoding="utf-8"))} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Could not read desktop.json: {e}")