fix(profiles): carry install records and skip install staging when cloning plugins

The light clone now copies plugins/ through the profile copier: bytecode caches,
sockets and the installer's in-flight .install-*/.update-* staging dirs stay
behind, while .install-metadata.json (revision + catalog provenance) travels, so
a catalog-installed memory provider runs at the source's revision in the clone
instead of resolving nowhere. The CLI names the plugins it carried.

Co-authored-by: Eri Barrett <eri@plasticlabs.ai>
This commit is contained in:
teknium1
2026-10-01 23:18:11 -07:00
committed by Teknium
parent 810c31c4c7
commit 85e11aaa00
4 changed files with 64 additions and 6 deletions

View File

@@ -215,6 +215,10 @@ def _profile_create(args):
print(f"Full copy from {source_label} (excluding session history, cron jobs, backups, and snapshots).")
else:
print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.")
from hermes_cli.profiles import cloned_plugin_names
plugin_names = cloned_plugin_names(profile_dir)
if plugin_names:
print(f"Cloned installed plugins too: {', '.join(plugin_names)}.")
from hermes_cli.profile_memory_config import cloned_memory_provider
memory_provider = cloned_memory_provider(profile_dir)
if memory_provider:

View File

@@ -1261,6 +1261,45 @@ def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None:
)
def _clone_plugins_ignore(plugins_root: Path):
"""copytree ignore for a cloned ``plugins/``: :func:`_non_exportable_entries` everywhere, plus
the installer's in-flight ``.install-*`` / ``.update-*`` staging dirs at the root."""
root = str(plugins_root)
def _ignore(directory: str, names: List[str]) -> set:
ignored = _non_exportable_entries(directory, names)
if directory == root:
# Directories only: ``.install-metadata.json`` shares the prefix and must travel.
ignored.update(n for n in names if n.startswith((".install-", ".update-"))
and os.path.isdir(os.path.join(directory, n)))
return ignored
return _ignore
def _clone_plugins(source_dir: Path, profile_dir: Path) -> None:
"""Copy the source's user-installed plugins (``plugins/`` with ``.install-metadata.json``).
``config.yaml`` already carries ``plugins.enabled`` and ``memory.provider``; without the code a
catalog-installed memory provider resolves nowhere in the clone, so the clone silently runs
without its memory (or, with lazy installs on, re-fetches the latest catalog pin instead of the
revision the source runs). The copy keeps each plugin's tree, revision and catalog provenance,
so ``hermes plugins update/remove`` work in the clone exactly as in the source. Python
dependencies live in the shared venv, already installed for the source's identical copy."""
source_plugins = source_dir / "plugins"
if source_plugins.is_dir():
_copytree_keep_junctions(source_plugins, profile_dir / "plugins",
_clone_plugins_ignore(source_plugins), dirs_exist_ok=True)
def cloned_plugin_names(profile_dir: Path) -> List[str]:
"""Plugins a clone now carries in ``plugins/``, for the CLI notice."""
try:
return sorted(p.name for p in (profile_dir / "plugins").iterdir()
if not p.name.startswith(".") and (p.is_dir() or p.is_symlink()))
except OSError:
return []
def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path],
sync_imports: bool = False) -> None:
"""Fresh layout: bootstrap dirs, then either seed a model block (no source) or clone
@@ -1284,11 +1323,7 @@ def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path],
_copytree_keep_junctions(source_skills, profile_dir / "skills", _non_exportable_entries, dirs_exist_ok=True)
for relpath in _CLONE_SUBDIR_FILES:
_clone_file(source_dir, profile_dir, relpath)
# Clone plugins from the source profile
source_plugins = source_dir / "plugins"
if source_plugins.is_dir():
shutil.copytree(source_plugins, profile_dir / "plugins",
symlinks=True, dirs_exist_ok=True)
_clone_plugins(source_dir, profile_dir)
from hermes_cli.profile_memory_config import active_memory_provider, clone_memory_provider_config
clone_memory_provider_config(source_dir, profile_dir,
active_memory_provider(_load_yaml_dict(source_dir / "config.yaml")))

View File

@@ -269,6 +269,25 @@ class TestCreateProfile:
/ "plugin.yaml"
).read_text() == "name: example-plugin\n"
def test_clone_config_keeps_plugin_provenance_and_skips_install_staging(self, profile_env):
"""A catalog-installed memory provider keeps its install record in the clone, so the clone
runs (and updates) the source's exact revision; an in-flight install staging dir stays behind."""
plugins = profile_env / ".hermes" / "plugins"
(plugins / "acme-memory").mkdir(parents=True)
(plugins / "acme-memory" / "plugin.yaml").write_text("name: acme-memory\nkind: memory\n")
(plugins / "acme-memory" / "__pycache__").mkdir()
(plugins / ".install-abc123" / "plugin").mkdir(parents=True)
record = '{"acme-memory": {"pinned": true, "revision": "%s", "source": "https://example.invalid/a.git"}}' % ("a" * 40)
(plugins / ".install-metadata.json").write_text(record)
(profile_env / ".hermes" / "config.yaml").write_text("memory:\n provider: acme-memory\n")
cloned = create_profile("coder", clone_config=True, no_alias=True) / "plugins"
assert (cloned / ".install-metadata.json").read_text() == record
assert (cloned / "acme-memory" / "plugin.yaml").is_file()
assert not (cloned / "acme-memory" / "__pycache__").exists()
assert not (cloned / ".install-abc123").exists()
@pytest.mark.parametrize("provider", ["../outside", "a/b", "..", "hind sight"])
def test_clone_config_ignores_unsafe_memory_provider_names(self, profile_env, provider):
"""A hand-edited ``memory.provider`` must never aim the copy outside the source profile."""

View File

@@ -80,7 +80,7 @@ You can also set or auto-generate the description later with `hermes profile des
hermes profile create work --clone
```
Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. If `config.yaml` selects an external memory provider (`memory.provider`), that provider's own config travels too — its `<provider>/` directory or `<provider>.json` under the profile home, e.g. `hindsight/config.json` — so the clone's memory is available instead of silently off; a cloned `local_embedded` hindsight config still shares the source's embedded daemon and bank until you give the clone its own hindsight `profile`/`bank_id` ([#81815](https://github.com/NousResearch/hermes-agent/issues/81815)). Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
Copies your current profile's `config.yaml`, `.env`, `SOUL.md`, skills, and the curated memory files `memories/MEMORY.md` and `memories/USER.md` into the new profile — memory is treated as part of the agent's identity, like `SOUL.md`. If `config.yaml` selects an external memory provider (`memory.provider`), that provider's own config travels too — its `<provider>/` directory or `<provider>.json` under the profile home, e.g. `hindsight/config.json` — so the clone's memory is available instead of silently off; a cloned `local_embedded` hindsight config still shares the source's embedded daemon and bank until you give the clone its own hindsight `profile`/`bank_id` ([#81815](https://github.com/NousResearch/hermes-agent/issues/81815)). Installed plugins travel too: `plugins/` (catalog, Git and hand-installed plugins, plus their install records) is copied, so a memory provider installed from the plugin catalog keeps working in the clone at the same revision, and `hermes plugins update`/`remove` work there as in the source. Plugin Python dependencies live in the shared environment and are not reinstalled. Sessions, `state.db`, cron jobs and everything else start empty. For a blank memory as well, create the profile without `--clone` or delete the two files afterwards; the agent never falls back to another profile's memory when they are absent. Edit `~/.hermes/profiles/work/.env` for different API keys, or `~/.hermes/profiles/work/SOUL.md` for a different personality.
#### Keep a clone's imported agent setups synced (`--sync-imports`)