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.
This commit is contained in:
Brooklyn Nicholson 2026-08-04 12:07:29 -06:00
parent 3fa318a50c
commit d1196750c0
3 changed files with 137 additions and 2 deletions

View File

@ -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)

View File

@ -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

View File

@ -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}")