Merge pull request #16419 from vincez-hms-coder/feat/dashboard-profiles-hms-coder
feat(dashboard): add profiles management page
This commit is contained in:
16
tests/hermes_cli/test_dashboard_browser_safe_imports.py
Normal file
16
tests/hermes_cli/test_dashboard_browser_safe_imports.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Static dashboard tests for browser-safe @nous-research/ui imports."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WEB_SRC = Path(__file__).resolve().parents[2] / "web" / "src"
|
||||
|
||||
|
||||
def test_dashboard_does_not_import_nous_ui_root_barrel():
|
||||
offenders = []
|
||||
for ext in ("*.tsx", "*.ts"):
|
||||
for path in WEB_SRC.rglob(ext):
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if 'from "@nous-research/ui"' in content or "from '@nous-research/ui'" in content:
|
||||
offenders.append(str(path.relative_to(WEB_SRC)))
|
||||
|
||||
assert offenders == []
|
||||
11
tests/hermes_cli/test_dashboard_profiles_nav_label.py
Normal file
11
tests/hermes_cli/test_dashboard_profiles_nav_label.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Static dashboard tests for the Profiles navigation copy."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_profiles_nav_label_uses_short_multi_agents_copy():
|
||||
en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts"
|
||||
|
||||
content = en_i18n.read_text(encoding="utf-8")
|
||||
|
||||
assert 'profiles: "profiles : multi agents"' in content
|
||||
assert "Profiles: Running Multiple Agents" not in content
|
||||
@@ -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"
|
||||
|
||||
@@ -591,6 +591,222 @@ class TestNewEndpoints:
|
||||
resp = self.client.get("/api/cron/jobs/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# --- Profiles ---
|
||||
|
||||
def test_profiles_list_includes_default(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
get_hermes_home().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resp = self.client.get("/api/profiles")
|
||||
assert resp.status_code == 200
|
||||
names = [p["name"] for p in resp.json()["profiles"]]
|
||||
assert "default" in names
|
||||
|
||||
def test_profiles_list_falls_back_when_profile_listing_fails(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
"model:\n provider: openrouter\n name: anthropic/claude-sonnet-4.6\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
named = hermes_home / "profiles" / "multi-agent"
|
||||
named.mkdir(parents=True)
|
||||
(named / ".env").write_text("EXAMPLE=1\n", encoding="utf-8")
|
||||
(named / "skills" / "demo").mkdir(parents=True)
|
||||
(named / "skills" / "demo" / "SKILL.md").write_text("---\nname: demo\n---\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
profiles_mod,
|
||||
"list_profiles",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
resp = self.client.get("/api/profiles")
|
||||
|
||||
assert resp.status_code == 200
|
||||
profiles = {p["name"]: p for p in resp.json()["profiles"]}
|
||||
assert profiles["default"]["is_default"] is True
|
||||
assert profiles["default"]["provider"] == "openrouter"
|
||||
assert profiles["multi-agent"]["has_env"] is True
|
||||
assert profiles["multi-agent"]["skill_count"] == 1
|
||||
|
||||
def test_profiles_create_rename_delete_round_trip(self, monkeypatch):
|
||||
# Stub gateway service teardown so the test doesn't shell out to
|
||||
# launchctl/systemctl on the host.
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None)
|
||||
|
||||
created = self.client.post("/api/profiles", json={"name": "test-prof"})
|
||||
assert created.status_code == 200
|
||||
|
||||
renamed = self.client.patch(
|
||||
"/api/profiles/test-prof",
|
||||
json={"new_name": "test-prof-2"},
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
|
||||
names = [p["name"] for p in self.client.get("/api/profiles").json()["profiles"]]
|
||||
assert "test-prof" not in names
|
||||
assert "test-prof-2" in names
|
||||
|
||||
deleted = self.client.delete("/api/profiles/test-prof-2")
|
||||
assert deleted.status_code == 200
|
||||
names = [p["name"] for p in self.client.get("/api/profiles").json()["profiles"]]
|
||||
assert "test-prof-2" not in names
|
||||
|
||||
def test_profile_setup_command_uses_named_profile_wrapper(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
(get_hermes_home() / "profiles" / "coder").mkdir(parents=True)
|
||||
|
||||
resp = self.client.get("/api/profiles/coder/setup-command")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["command"] == "coder setup"
|
||||
|
||||
def test_profile_setup_command_uses_hermes_for_default_profile(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
get_hermes_home().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resp = self.client.get("/api/profiles/default/setup-command")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["command"] == "hermes setup"
|
||||
|
||||
def test_profiles_create_creates_wrapper_alias_when_safe(self, monkeypatch, tmp_path):
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
wrapper_dir = tmp_path / "bin"
|
||||
wrapper_dir.mkdir()
|
||||
monkeypatch.setattr(profiles_mod, "_get_wrapper_dir", lambda: wrapper_dir)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={"name": "writer", "clone_from_default": False},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
wrapper_path = wrapper_dir / "writer"
|
||||
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
|
||||
|
||||
(get_hermes_home() / "profiles" / "coder").mkdir(parents=True)
|
||||
calls = []
|
||||
monkeypatch.setattr(web_server.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(web_server.subprocess, "Popen", lambda args, **kwargs: calls.append(args))
|
||||
|
||||
resp = self.client.post("/api/profiles/coder/open-terminal")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert calls
|
||||
assert calls[0][0] == "osascript"
|
||||
assert "coder setup" in " ".join(calls[0])
|
||||
|
||||
def test_profile_open_terminal_uses_windows_cmd(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
(get_hermes_home() / "profiles" / "coder").mkdir(parents=True)
|
||||
calls = []
|
||||
monkeypatch.setattr(web_server.sys, "platform", "win32")
|
||||
monkeypatch.setattr(web_server.subprocess, "Popen", lambda args, **kwargs: calls.append(args))
|
||||
|
||||
resp = self.client.post("/api/profiles/coder/open-terminal")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert calls
|
||||
assert calls[0][:4] == ["cmd.exe", "/c", "start", ""]
|
||||
assert calls[0][-1] == "coder setup"
|
||||
|
||||
def test_profiles_create_rejects_invalid_name(self):
|
||||
resp = self.client.post("/api/profiles", json={"name": "Has Spaces"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_profiles_delete_default_forbidden(self):
|
||||
resp = self.client.delete("/api/profiles/default")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_profiles_delete_not_found(self):
|
||||
resp = self.client.delete("/api/profiles/does-not-exist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_profile_soul_round_trip(self, monkeypatch):
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None)
|
||||
|
||||
self.client.post("/api/profiles", json={"name": "soul-prof"})
|
||||
get1 = self.client.get("/api/profiles/soul-prof/soul")
|
||||
assert get1.status_code == 200
|
||||
assert get1.json()["exists"] is True
|
||||
|
||||
put = self.client.put(
|
||||
"/api/profiles/soul-prof/soul",
|
||||
json={"content": "# Edited soul"},
|
||||
)
|
||||
assert put.status_code == 200
|
||||
|
||||
got = self.client.get("/api/profiles/soul-prof/soul").json()
|
||||
assert got["content"] == "# Edited soul"
|
||||
|
||||
self.client.delete("/api/profiles/soul-prof")
|
||||
|
||||
def test_profile_soul_unknown_profile_404(self):
|
||||
resp = self.client.get("/api/profiles/nonexistent/soul")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_skills_list(self):
|
||||
resp = self.client.get("/api/skills")
|
||||
assert resp.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user