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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1019 additions and 11 deletions

View file

@ -0,0 +1,144 @@
# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
Status: design proposal (not yet implemented)
Author: drafted for Teknium
Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
## Why this, not the CLI wizard
PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
The decision is to **not** build the profile-creation experience in the CLI.
The dashboard already owns mature, separate pages for every element a profile
needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
right home for a full-featured builder, and it can reuse everything that
already exists.
A profile = a full `~/.hermes/profiles/<name>/` directory with its own:
- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
- `.env` — secrets
- `SOUL.md` / `USER.md` — identity
So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
change needed. The gap is purely UX: creation today is a thin modal
(name + clone + model + description), and you can only compose skills/MCPs
*after* the profile exists, by visiting other pages and remembering to scope
them.
## What already exists (reuse, don't rebuild)
| Element | Existing page | Existing API | Profile-scopable? |
|---|---|---|---|
| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
## Two architectural seams found while grounding this design
These are load-bearing — they change the implementation, not just the polish.
### Seam #1 — hub-skill install cannot use the HERMES_HOME override
`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
import time**. The context-local `set_hermes_home_override()` swap (which makes
`_write_profile_model` and the MCP write land in the target profile) does NOT
retroactively rebind that already-imported module global. So a data-layer wrap
of hub install would write into the dashboard's *own* active profile, not the
new one.
The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
runs `python -m hermes_cli.main <subcommand>`, and `_apply_profile_override()`
re-reads `sys.argv` at import in the fresh child. Prepend `-p <profile>`:
```python
_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
```
A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
from the start, so `SKILLS_DIR` resolves to `<profile>/skills/`. Correct by
construction.
### Seam #2 — hub installs are async, so create cannot be fully atomic
Built-in/optional skill enabling and MCP writes are **synchronous config ops**
and can be part of the create call. Hub installs are long-running git fetches
spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
create flow is:
1. `create_profile()` — make the dir (synchronous)
2. write model (synchronous, HERMES_HOME override)
3. write selected MCP servers (synchronous, HERMES_HOME override)
4. seed/enable selected built-in + optional skills (synchronous)
5. spawn `hermes -p <profile> skills install <id>` per hub skill (async, returns PIDs)
Steps 14 commit before the response; step 5 returns a list of action PIDs the
UI polls (same pattern as today's SkillsPage hub install). The builder's
"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
final screen shows live install progress for the hub skills.
## Proposed backend change (small, follows existing patterns)
Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
```python
class ProfileCreate(BaseModel):
name: str
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# NEW — all optional, all best-effort post-create (profile already exists)
mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
builtin_skills: List[str] = [] # synchronous enable/seed
hub_skills: List[str] = [] # async spawn, returns PIDs
```
The endpoint already does best-effort post-create steps (`seed_profile_skills`,
`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
spawn) in the same style — a failure in any of them must not 500 the create,
since the profile dir already exists and the user can fix it from the relevant
page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
## Proposed frontend — dedicated builder page `/profiles/new`
A full page (not the cramped modal), stepped, each step reusing the existing
page's component + API, targeted at the new profile:
```
① Identity Name + Description (+ optional clone-from existing profile)
② Model Provider + model picker (reuse ModelsPage picker)
③ Skills Tabs: Built-in · Optional · Hub-search
multi-select; "Start from default bundle" preset button
④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
⑤ Review Blueprint preview → Create
→ progress screen for async hub installs
```
Nothing writes to disk until ⑤.
## Open product decisions (need Teknium)
1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
today. In the builder, should the skill step **replace** the bundle (pick
exactly what you want; offer a "start from default bundle" preset) or
**augment** it? Recommendation: replace + preset button.
2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
SOUL editing, multi-agent fleets later) vs a bigger create modal on
ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
more options."
## Verification plan (when built)
- Backend E2E with isolated HERMES_HOME: POST a full create body
(name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
profile dir has the model in config.yaml, both MCP servers in config.yaml,
the builtin skills enabled, and a spawned PID for the hub skill. Negative:
a bad MCP entry must not 500 the create.
- `cd web && npm run build` (no JS test suite in web/).
- Targeted: `pytest tests/<web_server profile tests> -k profile_create`.

View file

@ -7422,6 +7422,21 @@ class ProfileCreate(BaseModel):
clone_from: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# Profile-builder additions — all optional, all applied best-effort AFTER
# the profile directory exists, so a hiccup in any of them never 500s the
# create (the user can fix it from the relevant dashboard page afterward).
# MCP servers to write into the new profile's config.yaml.
mcp_servers: List["MCPServerCreate"] = []
# Built-in / optional skills to KEEP active. When this list is non-empty,
# the builder uses "replace" semantics: the bundle is seeded, then every
# seeded skill NOT in this list is added to the profile's disabled list.
# Empty list = leave the seeded bundle untouched (legacy behaviour).
keep_skills: List[str] = []
# Skills-hub identifiers to install into the new profile. Installed async
# via a subprocess scoped to the profile (`hermes -p <name> skills install`)
# because skills_hub.SKILLS_DIR is import-time-bound and the HERMES_HOME
# override can't redirect it. Returns spawned PIDs for the UI to poll.
hub_skills: List[str] = []
class ProfileRename(BaseModel):
@ -7567,6 +7582,94 @@ def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None:
reset_hermes_home_override(token)
def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate"]) -> int:
"""Write MCP server entries into a specific profile's config.yaml.
Scopes ``load_config``/``save_config`` to ``profile_dir`` via the
context-local HERMES_HOME override (same mechanism as
``_write_profile_model``) so the entries land in the target profile's
config rather than the dashboard process's active profile.
Mirrors the per-server shape the ``POST /api/mcp/servers`` endpoint builds,
but batched so the whole profile-create write is a single config save.
Returns the number of servers written.
"""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
written = 0
token = set_hermes_home_override(str(profile_dir))
try:
cfg = load_config()
mcp = cfg.setdefault("mcp_servers", {})
for server in servers:
name = (server.name or "").strip()
if not name:
continue
entry: Dict[str, Any] = {}
if server.url:
entry["url"] = server.url
if server.command:
entry["command"] = server.command
if server.args:
entry["args"] = list(server.args)
if server.env:
entry["env"] = dict(server.env)
if server.auth:
entry["auth"] = server.auth
if not entry:
# Nothing usable to write (neither url nor command) — skip
# rather than persist an empty, unusable server stanza.
continue
mcp[name] = entry
written += 1
if written:
save_config(cfg)
elif not mcp:
# We created an empty mcp_servers dict but wrote nothing — don't
# leave a stray empty key in the new profile's config.
cfg.pop("mcp_servers", None)
save_config(cfg)
finally:
reset_hermes_home_override(token)
return written
def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int:
"""Disable every installed skill in ``profile_dir`` not in ``keep``.
Profiles manage skill activation via a *disabled* list all installed
skills are active by default and users opt out. The builder's skill step
uses "replace" semantics: the user picks exactly which seeded built-in /
optional skills stay active, and everything else gets added to the disabled
list. (Hub skills are installed separately via subprocess and are active on
install.) Scoped to the profile via the HERMES_HOME override. Returns the
number of skills newly disabled.
"""
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
keep_set = {s.strip() for s in keep if s and s.strip()}
disabled_count = 0
token = set_hermes_home_override(str(profile_dir))
try:
installed: List[str] = []
skills_root = profile_dir / "skills"
if skills_root.is_dir():
for md in skills_root.rglob("SKILL.md"):
installed.append(md.parent.name)
cfg = load_config()
disabled = get_disabled_skills(cfg)
for name in installed:
if name not in keep_set and name not in disabled:
disabled.add(name)
disabled_count += 1
if disabled_count:
save_disabled_skills(cfg, disabled)
finally:
reset_hermes_home_override(token)
return disabled_count
@app.get("/api/profiles")
async def list_profiles_endpoint():
from hermes_cli import profiles as profiles_mod
@ -7633,7 +7736,55 @@ async def create_profile_endpoint(body: ProfileCreate):
except Exception:
_log.exception("Setting model for new profile %s failed", body.name)
return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set}
# Optional MCP servers. Best-effort, same rationale as model assignment.
mcp_written = 0
if body.mcp_servers:
try:
mcp_written = _write_profile_mcp_servers(path, body.mcp_servers)
except Exception:
_log.exception("Writing MCP servers for new profile %s failed", body.name)
# Optional "keep" skill selection — replace semantics. When the builder
# sends an explicit keep list, disable every seeded skill not in it.
# Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle).
skills_disabled = 0
if body.keep_skills:
try:
skills_disabled = _disable_unselected_skills(path, body.keep_skills)
except Exception:
_log.exception("Applying skill selection for new profile %s failed", body.name)
# Optional skills-hub installs. Spawned async, scoped to the new profile
# via `-p <name>` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the
# profile's HERMES_HOME at import). Returns PIDs for the UI to poll.
hub_installs: List[Dict[str, Any]] = []
for identifier in body.hub_skills:
ident = (identifier or "").strip()
if not ident:
continue
try:
proc = _spawn_hermes_action(
["-p", body.name, "skills", "install", ident],
"skills-install",
)
hub_installs.append({"identifier": ident, "pid": proc.pid})
except Exception:
_log.exception(
"Spawning hub-skill install %s for new profile %s failed",
ident,
body.name,
)
hub_installs.append({"identifier": ident, "pid": None})
return {
"ok": True,
"name": body.name,
"path": str(path),
"model_set": model_set,
"mcp_written": mcp_written,
"skills_disabled": skills_disabled,
"hub_installs": hub_installs,
}
@app.get("/api/profiles/active")

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

View file

@ -74,6 +74,7 @@ import AnalyticsPage from "@/pages/AnalyticsPage";
import ModelsPage from "@/pages/ModelsPage";
import CronPage from "@/pages/CronPage";
import ProfilesPage from "@/pages/ProfilesPage";
import ProfileBuilderPage from "@/pages/ProfileBuilderPage";
import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import McpPage from "@/pages/McpPage";
@ -136,6 +137,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/webhooks": WebhooksPage,
"/system": SystemPage,
"/profiles": ProfilesPage,
"/profiles/new": ProfileBuilderPage,
"/config": ConfigPage,
"/env": EnvPage,
"/docs": DocsPage,

View file

@ -439,8 +439,19 @@ export const api = {
description?: string;
provider?: string;
model?: string;
mcp_servers?: McpServerCreate[];
keep_skills?: string[];
hub_skills?: string[];
}) =>
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
fetchJSON<{
ok: boolean;
name: string;
path: string;
model_set?: boolean;
mcp_written?: number;
skills_disabled?: number;
hub_installs?: Array<{ identifier: string; pid: number | null }>;
}>("/api/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),

View file

@ -0,0 +1,611 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { api } from "@/lib/api";
import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api";
import { cn } from "@/lib/utils";
// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`).
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
type StepId = "identity" | "model" | "skills" | "mcp" | "review";
const STEPS: { id: StepId; label: string }[] = [
{ id: "identity", label: "Identity" },
{ id: "model", label: "Model" },
{ id: "skills", label: "Skills" },
{ id: "mcp", label: "MCPs" },
{ id: "review", label: "Review" },
];
interface ModelChoice {
provider: string;
model: string;
label: string;
}
/**
* Dashboard-native, full-featured profile builder.
*
* Composes the same elements the standalone Models / Skills / MCP pages
* manage Name, Description, Model+Provider, Skills (built-in/optional +
* hub), MCP servers into one stepped create flow. Nothing is written to
* disk until "Create profile" on the final step; the single POST /api/profiles
* call commits model + MCPs + skill selection synchronously and spawns any
* hub-skill installs (which the success toast reports as in-progress).
*
* Skills use REPLACE semantics: the default bundle is seeded server-side, then
* every seeded skill the user did NOT keep is disabled. The "Start from full
* bundle" toggle keeps everything (sends no keep list).
*/
export default function ProfileBuilderPage() {
const navigate = useNavigate();
const { toast, showToast } = useToast();
const [step, setStep] = useState<StepId>("identity");
// ── Step 1: identity ──────────────────────────────────────────────
const [name, setName] = useState("");
const [description, setDescription] = useState("");
// ── Step 2: model ─────────────────────────────────────────────────
const [modelChoices, setModelChoices] = useState<ModelChoice[] | null>(null);
const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}`
const [modelFilter, setModelFilter] = useState("");
const modelLoading = useRef(false);
// ── Step 3: skills ────────────────────────────────────────────────
const [skills, setSkills] = useState<SkillInfo[] | null>(null);
// keepAll = true: don't send a keep list (full bundle stays active).
const [keepAll, setKeepAll] = useState(true);
const [keptSkills, setKeptSkills] = useState<Set<string>>(new Set());
const [skillFilter, setSkillFilter] = useState("");
const skillsLoading = useRef(false);
// Hub search
const [hubQuery, setHubQuery] = useState("");
const [hubResults, setHubResults] = useState<SkillHubResult[]>([]);
const [hubSearching, setHubSearching] = useState(false);
const [hubSkills, setHubSkills] = useState<SkillHubResult[]>([]);
// ── Step 4: MCPs ──────────────────────────────────────────────────
const [mcpServers, setMcpServers] = useState<McpServerCreate[]>([]);
const [mcpDraft, setMcpDraft] = useState<{
name: string;
url: string;
command: string;
args: string;
}>({ name: "", url: "", command: "", args: "" });
// ── Submit ────────────────────────────────────────────────────────
const [creating, setCreating] = useState(false);
const nameValid = PROFILE_NAME_RE.test(name.trim());
// Lazy-load model choices when the model step is first shown.
const loadModels = useCallback(() => {
if (modelChoices !== null || modelLoading.current) return;
modelLoading.current = true;
api
.getModelOptions()
.then((res) => {
const flat: ModelChoice[] = [];
for (const prov of res.providers ?? []) {
for (const m of prov.models ?? []) {
flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` });
}
}
setModelChoices(flat);
})
.catch(() => setModelChoices([]))
.finally(() => {
modelLoading.current = false;
});
}, [modelChoices]);
const loadSkills = useCallback(() => {
if (skills !== null || skillsLoading.current) return;
skillsLoading.current = true;
api
.getSkills()
.then((res) => {
setSkills(res);
// Default keep = all currently-enabled skills (matches the seeded set).
setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name)));
})
.catch(() => setSkills([]))
.finally(() => {
skillsLoading.current = false;
});
}, [skills]);
useEffect(() => {
if (step === "model") loadModels();
if (step === "skills") loadSkills();
}, [step, loadModels, loadSkills]);
const runHubSearch = useCallback(() => {
const q = hubQuery.trim();
if (!q) return;
setHubSearching(true);
api
.searchSkillsHub(q, "all", 20)
.then((res) => setHubResults(res.results ?? []))
.catch(() => setHubResults([]))
.finally(() => setHubSearching(false));
}, [hubQuery]);
const toggleKeep = (skillName: string) => {
setKeptSkills((prev) => {
const next = new Set(prev);
if (next.has(skillName)) next.delete(skillName);
else next.add(skillName);
return next;
});
};
const addHubSkill = (r: SkillHubResult) => {
setHubSkills((prev) =>
prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r],
);
};
const removeHubSkill = (identifier: string) =>
setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier));
const addMcpDraft = () => {
const n = mcpDraft.name.trim();
if (!n) {
showToast("MCP server needs a name", "error");
return;
}
if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) {
showToast("Give the MCP server a URL or a command", "error");
return;
}
const entry: McpServerCreate = { name: n };
if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim();
if (mcpDraft.command.trim()) {
entry.command = mcpDraft.command.trim();
const args = mcpDraft.args.trim();
if (args) entry.args = args.split(/\s+/);
}
setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]);
setMcpDraft({ name: "", url: "", command: "", args: "" });
};
const removeMcp = (n: string) =>
setMcpServers((prev) => prev.filter((s) => s.name !== n));
const filteredModels = useMemo(() => {
if (!modelChoices) return [];
const f = modelFilter.trim().toLowerCase();
if (!f) return modelChoices;
return modelChoices.filter((c) => c.label.toLowerCase().includes(f));
}, [modelChoices, modelFilter]);
const filteredSkills = useMemo(() => {
if (!skills) return [];
const f = skillFilter.trim().toLowerCase();
if (!f) return skills;
return skills.filter(
(s) =>
s.name.toLowerCase().includes(f) ||
(s.description || "").toLowerCase().includes(f) ||
(s.category || "").toLowerCase().includes(f),
);
}, [skills, skillFilter]);
const pickedModel = useMemo(
() =>
modelChoice
? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice)
: undefined,
[modelChoice, modelChoices],
);
const handleCreate = async () => {
const n = name.trim();
if (!PROFILE_NAME_RE.test(n)) {
showToast("Invalid profile name (lowercase, digits, - and _)", "error");
setStep("identity");
return;
}
setCreating(true);
try {
const res = await api.createProfile({
name: n,
clone_from_default: false,
description: description.trim() || undefined,
provider: pickedModel?.provider,
model: pickedModel?.model,
mcp_servers: mcpServers.length ? mcpServers : undefined,
keep_skills: keepAll ? undefined : Array.from(keptSkills),
hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined,
});
const pending = (res.hub_installs ?? []).filter((h) => h.pid).length;
showToast(
pending
? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing`
: `Profile "${n}" created`,
"success",
);
navigate("/profiles");
} catch (e) {
showToast(`Create failed: ${e}`, "error");
} finally {
setCreating(false);
}
};
const stepIndex = STEPS.findIndex((s) => s.id === step);
const canAdvance = step !== "identity" || nameValid;
return (
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
<div className="flex items-center justify-between">
<H2>New profile</H2>
<Button ghost onClick={() => navigate("/profiles")}>
Cancel
</Button>
</div>
{/* Stepper */}
<div className="flex items-center gap-2 text-sm">
{STEPS.map((s, i) => (
<button
key={s.id}
// Identity must be valid before jumping ahead.
disabled={i > 0 && !nameValid}
onClick={() => setStep(s.id)}
className={cn(
"rounded-full px-3 py-1 transition-colors",
s.id === step
? "bg-primary text-primary-foreground"
: i <= stepIndex
? "bg-muted text-foreground"
: "text-muted-foreground",
i > 0 && !nameValid && "cursor-not-allowed opacity-50",
)}
>
{i + 1}. {s.label}
</button>
))}
</div>
<Card>
<CardContent className="space-y-4 p-5">
{step === "identity" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="pb-name">Profile name</Label>
<Input
id="pb-name"
placeholder="coder"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
/>
{name && !nameValid && (
<p className="text-xs text-destructive">
Lowercase letters, digits, hyphens and underscores; must start with a letter or digit.
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="pb-desc">Description (optional)</Label>
<Input
id="pb-desc"
placeholder="What this agent profile is for"
value={description}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDescription(e.target.value)
}
/>
</div>
</div>
)}
{step === "model" && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Pick the model+provider for this profile. Skip to use the default.
</p>
<Input
placeholder="Filter models…"
value={modelFilter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setModelFilter(e.target.value)
}
/>
{modelChoices === null ? (
<p className="text-sm text-muted-foreground">Loading models</p>
) : (
<div className="max-h-72 space-y-1 overflow-y-auto">
<button
onClick={() => setModelChoice("")}
className={cn(
"block w-full rounded px-3 py-2 text-left text-sm",
modelChoice === "" ? "bg-primary/10" : "hover:bg-muted",
)}
>
Use default (set later)
</button>
{filteredModels.map((c) => {
const key = `${c.provider}\u0000${c.model}`;
return (
<button
key={key}
onClick={() => setModelChoice(key)}
className={cn(
"block w-full rounded px-3 py-2 text-left text-sm",
modelChoice === key ? "bg-primary/10" : "hover:bg-muted",
)}
>
{c.label}
</button>
);
})}
</div>
)}
</div>
)}
{step === "skills" && (
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={keepAll}
onCheckedChange={(v) => setKeepAll(Boolean(v))}
/>
Start from the full default skill bundle (recommended)
</label>
{!keepAll && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile.
</p>
<Input
placeholder="Filter skills…"
value={skillFilter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSkillFilter(e.target.value)
}
/>
{skills === null ? (
<p className="text-sm text-muted-foreground">Loading skills</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto">
{filteredSkills.map((s) => (
<label
key={s.name}
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
>
<Checkbox
checked={keptSkills.has(s.name)}
onCheckedChange={() => toggleKeep(s.name)}
/>
<span className="flex-1">
<span className="font-medium">{s.name}</span>
{s.category && (
<Badge tone="secondary" className="ml-2">
{s.category}
</Badge>
)}
{s.description && (
<span className="block text-xs text-muted-foreground">
{s.description}
</span>
)}
</span>
</label>
))}
</div>
)}
</div>
)}
{/* Skills hub */}
<div className="space-y-2 border-t pt-4">
<Label>Add from the skills hub</Label>
<div className="flex gap-2">
<Input
placeholder="Search the hub (e.g. linear, hyperliquid)…"
value={hubQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setHubQuery(e.target.value)
}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") runHubSearch();
}}
/>
<Button outlined onClick={runHubSearch} disabled={hubSearching}>
{hubSearching ? "Searching…" : "Search"}
</Button>
</div>
{hubResults.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto">
{hubResults.map((r) => (
<div
key={r.identifier}
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted"
>
<span className="flex-1">
<span className="font-medium">{r.name}</span>
<Badge tone="secondary" className="ml-2">
{r.source}
</Badge>
{r.description && (
<span className="block text-xs text-muted-foreground">
{r.description}
</span>
)}
</span>
<Button size="sm" ghost onClick={() => addHubSkill(r)}>
Add
</Button>
</div>
))}
</div>
)}
{hubSkills.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{hubSkills.map((r) => (
<Badge key={r.identifier} className="gap-1">
{r.name}
<button
className="ml-1 text-xs"
onClick={() => removeHubSkill(r.identifier)}
aria-label={`Remove ${r.name}`}
>
×
</button>
</Badge>
))}
</div>
)}
</div>
</div>
)}
{step === "mcp" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args.
</p>
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="Server name"
value={mcpDraft.name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, name: e.target.value })
}
/>
<Input
placeholder="URL (https://…/mcp)"
value={mcpDraft.url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, url: e.target.value })
}
/>
<Input
placeholder="Command (e.g. npx)"
value={mcpDraft.command}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, command: e.target.value })
}
/>
<Input
placeholder="Args (space-separated)"
value={mcpDraft.args}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMcpDraft({ ...mcpDraft, args: e.target.value })
}
/>
</div>
<Button outlined onClick={addMcpDraft}>
Add server
</Button>
{mcpServers.length > 0 && (
<div className="space-y-1">
{mcpServers.map((s) => (
<div
key={s.name}
className="flex items-center justify-between rounded bg-muted px-3 py-1.5 text-sm"
>
<span>
<span className="font-medium">{s.name}</span>{" "}
<span className="text-xs text-muted-foreground">
{s.url || `${s.command} ${(s.args || []).join(" ")}`}
</span>
</span>
<button
className="text-xs text-destructive"
onClick={() => removeMcp(s.name)}
>
Remove
</button>
</div>
))}
</div>
)}
</div>
)}
{step === "review" && (
<div className="space-y-3 text-sm">
<ReviewRow label="Name" value={name.trim() || "—"} />
<ReviewRow label="Description" value={description.trim() || "—"} />
<ReviewRow
label="Model"
value={pickedModel ? pickedModel.label : "Default (set later)"}
/>
<ReviewRow
label="Skills"
value={
keepAll
? "Full default bundle"
: `${keptSkills.size} built-in/optional kept` +
(hubSkills.length ? ` + ${hubSkills.length} hub` : "")
}
/>
{!keepAll && hubSkills.length > 0 && (
<p className="pl-24 text-xs text-muted-foreground">
Hub: {hubSkills.map((s) => s.name).join(", ")}
</p>
)}
{keepAll && hubSkills.length > 0 && (
<ReviewRow
label="Hub skills"
value={hubSkills.map((s) => s.name).join(", ")}
/>
)}
<ReviewRow
label="MCP servers"
value={mcpServers.length ? mcpServers.map((s) => s.name).join(", ") : "None"}
/>
</div>
)}
</CardContent>
</Card>
{/* Nav buttons */}
<div className="flex items-center justify-between">
<Button
ghost
disabled={stepIndex === 0}
onClick={() => setStep(STEPS[Math.max(0, stepIndex - 1)].id)}
>
Back
</Button>
{step === "review" ? (
<Button onClick={handleCreate} disabled={creating || !nameValid}>
{creating ? "Creating…" : "Create profile"}
</Button>
) : (
<Button
disabled={!canAdvance}
onClick={() => setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)}
>
Next
</Button>
)}
</div>
<Toast toast={toast} />
</div>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-3">
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
<span className="flex-1 break-words">{value}</span>
</div>
);
}

View file

@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import { useNavigate } from "react-router-dom";
import {
AlignLeft,
Check,
@ -246,6 +247,7 @@ export default function ProfilesPage() {
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
const navigate = useNavigate();
// Locale strings with English fallbacks. The enriched keys are optional in
// the i18n type so untranslated locales don't break the build — they render
@ -722,21 +724,31 @@ export default function ProfilesPage() {
: base;
})();
// Put "Create" button in page header
// Put "Build" (full builder) + "Create" (quick modal) buttons in header
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>,
<div className="flex items-center gap-2">
<Button
className="uppercase"
size="sm"
outlined
onClick={() => navigate("/profiles/new")}
>
Build
</Button>
<Button
className="uppercase"
size="sm"
onClick={() => setCreateModalOpen(true)}
>
{t.common.create}
</Button>
</div>,
);
return () => {
setEnd(null);
};
}, [setEnd, t.common.create, loading]);
}, [setEnd, t.common.create, loading, navigate]);
const cloning = cloneAll || cloneFromDefault;