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:
Teknium
2026-06-10 09:18:32 -07:00
committed by GitHub
parent 4cecb1a13a
commit d986bb0c6d
7 changed files with 1019 additions and 11 deletions
+77
View File
@@ -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