diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index 4289b036c0..e13115b03e 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -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: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index e9fc380b29..086039fea9 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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"))) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 5cf474a6a5..e2f50af48d 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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.""" diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 94904c0a03..2b8034da18 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -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 `/` directory or `.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 `/` directory or `.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`)