diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ba526354a3..def2ef34ff 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7490,7 +7490,7 @@ def cmd_profile(args): if clone_all: print(f"Full copy from {source_label}.") else: - print(f"Cloned config, .env, SOUL.md from {source_label}.") + print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.") # Auto-clone Honcho config for the new profile (only with --clone/--clone-all) if clone or clone_all: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index bf5d79b0b8..872d59563c 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -11,7 +11,7 @@ zero migration needed. Usage:: hermes profile create coder # fresh profile + bundled skills - hermes profile create coder --clone # also copy config, .env, SOUL.md + hermes profile create coder --clone # also copy config, .env, SOUL.md, skills hermes profile create coder --clone-all # full copy of source profile coder chat # use via wrapper alias hermes -p coder chat # or via flag @@ -388,7 +388,8 @@ def create_profile( clone_all: If True, do a full copytree of the source (all state). clone_config: - If True, copy only config files (config.yaml, .env, SOUL.md). + If True, copy config files (config.yaml, .env, SOUL.md), installed + skills, and selected profile identity files from the source profile. no_alias: If True, skip wrapper script creation. @@ -442,6 +443,14 @@ def create_profile( if src.exists(): shutil.copy2(src, profile_dir / filename) + # Clone installed skills from the source profile. The dashboard's + # "clone from default" flow is expected to preserve both bundled + # and user-installed skills so the new profile immediately has the + # same agent capabilities as the source profile. + source_skills = source_dir / "skills" + if source_skills.is_dir(): + shutil.copytree(source_skills, profile_dir / "skills", dirs_exist_ok=True) + # Clone memory and other subdirectory files for relpath in _CLONE_SUBDIR_FILES: src = source_dir / relpath diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6f3fadc873..b4ee6077bc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2214,6 +2214,13 @@ async def create_profile_endpoint(body: ProfileCreate): clone_from="default" if body.clone_from_default else None, clone_config=body.clone_from_default, ) + # Match the CLI's profile-create flow: fresh named profiles get the + # bundled skills installed. When cloning from default, create_profile() + # has already copied the source profile's skills, including any + # user-installed skills. + if not body.clone_from_default: + profiles_mod.seed_profile_skills(path, quiet=True) + # Match the CLI's profile-create flow: named profiles should get a # wrapper in ~/.local/bin when the alias is safe to create. collision = profiles_mod.check_alias_collision(body.name) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index b8876a764c..bcf4da244e 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -149,6 +149,23 @@ class TestCreateProfile: assert (profile_dir / ".env").read_text() == "KEY=val" assert (profile_dir / "SOUL.md").read_text() == "Be helpful." + def test_clone_config_copies_source_skills(self, profile_env): + tmp_path = profile_env + default_home = tmp_path / ".hermes" + skill_dir = default_home / "skills" / "custom" / "installed-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: installed-skill\n---\n") + + profile_dir = create_profile("coder", clone_config=True, no_alias=True) + + assert ( + profile_dir + / "skills" + / "custom" + / "installed-skill" + / "SKILL.md" + ).read_text() == "---\nname: installed-skill\n---\n" + def test_clone_all_copies_entire_tree(self, profile_env): tmp_path = profile_env default_home = tmp_path / ".hermes" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b6537f2cc8..60390d47ce 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -684,6 +684,51 @@ class TestNewEndpoints: assert wrapper_path.exists() assert wrapper_path.read_text() == '#!/bin/sh\nexec hermes -p writer "$@"\n' + def test_profiles_create_with_clone_from_default_copies_default_skills(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + default_skill = get_hermes_home() / "skills" / "custom" / "new-skill" + default_skill.mkdir(parents=True) + (default_skill / "SKILL.md").write_text("---\nname: new-skill\n---\n", encoding="utf-8") + + resp = self.client.post( + "/api/profiles", + json={"name": "cloned", "clone_from_default": True}, + ) + + assert resp.status_code == 200 + cloned_skill = get_hermes_home() / "profiles" / "cloned" / "skills" / "custom" / "new-skill" / "SKILL.md" + assert cloned_skill.exists() + profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} + assert profiles["cloned"]["skill_count"] == 1 + + def test_profiles_create_without_clone_seeds_bundled_skills(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + def fake_seed(profile_dir, quiet=False): + skill_dir = profile_dir / "skills" / "software-development" / "plan" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: plan\n---\n", encoding="utf-8") + return {"copied": ["plan"]} + + monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed) + + resp = self.client.post( + "/api/profiles", + json={"name": "fresh", "clone_from_default": False}, + ) + + assert resp.status_code == 200 + seeded_skill = get_hermes_home() / "profiles" / "fresh" / "skills" / "software-development" / "plan" / "SKILL.md" + assert seeded_skill.exists() + profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} + assert profiles["fresh"]["skill_count"] == 1 + def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch): from hermes_constants import get_hermes_home import hermes_cli.web_server as web_server