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:
parent
3fa318a50c
commit
d1196750c0
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in New Issue