feat(dashboard): full-featured profile builder (model + skills + MCPs) (#39084)
* feat(profiles): extend create endpoint for full profile-builder (model + MCPs + skills)
Backend foundation for the dashboard profile builder. Extends POST /api/profiles
to accept, in one call, everything a profile needs beyond name/clone:
- mcp_servers[] -> written into the new profile's config.yaml
- keep_skills[] -> replace-semantics: disable every seeded skill not kept
- hub_skills[] -> async install via 'hermes -p <name> skills install <id>'
All applied best-effort AFTER the profile dir exists, so a hiccup in any one
never 500s the create. Model/MCP/keep-skills writes are profile-scoped via the
HERMES_HOME context override (same mechanism as the existing _write_profile_model).
Hub installs go through a subprocess scoped with -p because skills_hub.SKILLS_DIR
is import-time-bound and the runtime override can't redirect it.
Adds two helpers (_write_profile_mcp_servers, _disable_unselected_skills) and a
TestClient test asserting all four paths land in the NEW profile's config and
the hub spawn is scoped to it. Design doc at docs/design/profile-builder.md.
* feat(dashboard): full-featured profile builder page
Adds a dedicated /profiles/new builder that composes everything a profile
needs into one stepped create flow, reusing the existing Models/Skills/MCP
data paths instead of duplicating them:
- Identity name + description
- Model provider+model picker (api.getModelOptions)
- Skills keep-which-built-in/optional (replace semantics, default = full
bundle) + skills-hub search/add (api.getSkills, searchSkillsHub)
- MCPs add HTTP/stdio servers inline
- Review blueprint -> single POST /api/profiles create
Nothing writes until Create; the one call commits model+MCPs+skill selection
and spawns hub-skill installs (reported in the success toast). ProfilesPage
header gets a 'Build' button (full builder) alongside 'Create' (quick modal).
Route is page-only (not in the sidebar nav). Verified with vite build (2258
modules, green).
This commit is contained in:
@@ -2425,6 +2425,83 @@ class TestNewEndpoints:
|
||||
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
|
||||
assert profiles["fresh"]["skill_count"] == 1
|
||||
|
||||
def test_profiles_create_builder_fields_model_mcp_and_keep_skills(self, monkeypatch):
|
||||
"""Profile-builder create: model + MCP servers + keep-skills selection
|
||||
all land in the NEW profile's config, and hub installs are spawned
|
||||
scoped to that profile via ``-p <name>``."""
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
set_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
)
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.skills_config import get_disabled_skills
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
|
||||
|
||||
# Seed two known skills so keep-skills "replace" has something to act on.
|
||||
def fake_seed(profile_dir, quiet=False):
|
||||
for skill in ("keep-me", "drop-me"):
|
||||
d = profile_dir / "skills" / "custom" / skill
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(f"---\nname: {skill}\n---\n", encoding="utf-8")
|
||||
return {"copied": ["keep-me", "drop-me"]}
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed)
|
||||
|
||||
# Capture hub-install spawns instead of launching real subprocesses.
|
||||
spawned = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4321
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
spawned.append((list(subcommand), name))
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={
|
||||
"name": "builder",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"mcp_servers": [
|
||||
{"name": "ctx7", "url": "https://mcp.context7.com/mcp"},
|
||||
{"name": "bogus"}, # no url/command -> must be skipped, no 500
|
||||
],
|
||||
"keep_skills": ["keep-me"],
|
||||
"hub_skills": ["someuser/some-skill"],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["model_set"] is True
|
||||
assert data["mcp_written"] == 1 # bogus skipped
|
||||
assert data["skills_disabled"] == 1 # drop-me disabled, keep-me kept
|
||||
assert data["hub_installs"] == [{"identifier": "someuser/some-skill", "pid": 4321}]
|
||||
|
||||
# Hub install was scoped to the new profile.
|
||||
assert spawned == [(["-p", "builder", "skills", "install", "someuser/some-skill"], "skills-install")]
|
||||
|
||||
# Verify the writes landed in the NEW profile's config, not the root.
|
||||
prof_dir = get_hermes_home() / "profiles" / "builder"
|
||||
token = set_hermes_home_override(str(prof_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert cfg["model"]["provider"] == "openrouter"
|
||||
assert sorted((cfg.get("mcp_servers") or {}).keys()) == ["ctx7"]
|
||||
disabled = get_disabled_skills(cfg)
|
||||
assert "drop-me" in disabled
|
||||
assert "keep-me" not in disabled
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user