Compare commits

...

6 Commits

Author SHA1 Message Date
ethernet
38368d17da fix(ci): only save test durations if all tests passed
otherwise slices could get weird
2026-06-10 13:22:29 -04:00
Teknium
07ac185904 fix(ci): exit-4 forensics for vanishing test files in run_tests_parallel.py (#43646)
* fix(ci): append filesystem forensics when a per-file pytest run exhausts exit-4 retries

A PR-added test file (tests/test_iron_proxy.py, PR #30179) repeatedly
failed exactly one CI shard with 'ERROR: file or directory not found'
across 4 runs (including a fresh merge SHA on fresh runners), while the
identical slice passes locally against the same merge commit and a
tree-integrity watcher confirms no sibling test mutates the repo. Three
unrelated branches showed the same one-shard signature the same day.

We currently cannot attribute these because the log only carries
pytest's exit-4 line. This adds a forensics block to the captured
output when exit-4 survives the retry loop:

- does the file exist NOW (post-retries)
- parent dir entry count + similarly-named entries
- git status --porcelain dirty-entry count + first 10 entries

Zero behavior change: rc stays 4, retries unchanged, forensics wrapped
in a broad try/except so they can never mask the failure.

Two new tests cover the exhausted-retries and genuinely-missing paths.

* chore: drop the two forensics tests — ship the runner change only
2026-06-10 10:04:17 -07:00
Shannon Sands
3acf73161f Move folder creation into dialog 2026-06-10 09:53:12 -07:00
Shannon Sands
dd60c49bb8 Add dashboard file drop upload panel 2026-06-10 09:53:12 -07:00
Shannon Sands
6fe4821926 Add dashboard file browser paths 2026-06-10 09:53:12 -07:00
Teknium
d986bb0c6d 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).
2026-06-10 09:18:32 -07:00
11 changed files with 2223 additions and 12 deletions

View File

@@ -125,7 +125,7 @@ jobs:
# (including PRs) get balanced slicing.
save-durations:
needs: test
if: always() && github.ref == 'refs/heads/main'
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Download all slice durations

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

@@ -20,9 +20,11 @@ import hmac
import importlib.util
import json
import logging
import mimetypes
import os
import re
import secrets
import shutil
import stat
import subprocess
import sys
@@ -657,6 +659,21 @@ class AudioTranscriptionRequest(BaseModel):
mime_type: Optional[str] = None
class ManagedFileUpload(BaseModel):
path: str
data_url: str
overwrite: bool = True
class ManagedDirectoryCreate(BaseModel):
path: str
class ManagedFileDelete(BaseModel):
path: str
recursive: bool = False
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac",
"audio/flac": ".flac",
@@ -819,6 +836,16 @@ _MEDIA_CONTENT_TYPES = {
".ico": "image/x-icon",
}
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT"
_MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024
_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data")
@dataclass(frozen=True)
class ManagedFilesPolicy:
default_path: Path
locked_root: Path | None
can_change_path: bool
def _media_serve_roots() -> list[Path]:
@@ -874,6 +901,297 @@ async def get_media(path: str):
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
def _canonical_path(path: Path, *, require_exists: bool = False) -> Path:
try:
return path.expanduser().resolve(strict=require_exists)
except FileNotFoundError:
if require_exists:
raise HTTPException(status_code=404, detail="Path not found")
raise
except (OSError, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid path")
def _ensure_managed_root(raw_path: str | Path) -> Path:
root = Path(raw_path).expanduser()
try:
root.mkdir(parents=True, exist_ok=True)
resolved = root.resolve()
except (OSError, RuntimeError) as exc:
raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}")
if not resolved.is_dir():
raise HTTPException(status_code=500, detail="Managed files root is not a directory")
return resolved
def _path_is_under(root: Path, target: Path) -> bool:
return target == root or root in target.parents
def _path_text(raw_path: str | None) -> str:
text = str(raw_path or "").strip()
if "\x00" in text:
raise HTTPException(status_code=400, detail="Invalid path")
return text
def _local_dashboard_request(request: Request) -> bool:
if getattr(request.app.state, "auth_required", False):
return False
host = (request.url.hostname or "").lower()
client_host = (request.client.host if request.client else "").lower()
local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"}
return host in local_hosts or client_host in local_hosts
def _default_hermes_root_is_opt_data() -> bool:
raw = os.environ.get("HERMES_HOME", "").strip()
if not raw:
return False
try:
from hermes_constants import get_default_hermes_root
root = get_default_hermes_root().expanduser().resolve(strict=False)
except (OSError, RuntimeError):
root = Path(raw).expanduser().resolve(strict=False)
return root == _HOSTED_MANAGED_FILES_ROOT
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
if raw_forced_root:
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data():
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
home = _canonical_path(Path.home())
return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True)
def _resolve_managed_path(
raw_path: str | None,
request: Request,
*,
for_write: bool = False,
) -> tuple[ManagedFilesPolicy, Path, str]:
policy = _managed_files_policy(request)
text = _path_text(raw_path)
root = policy.locked_root
if root is not None and (not text or text in {".", "/"}):
candidate = root
elif not text:
candidate = policy.default_path
else:
candidate = Path(text).expanduser()
if root is not None and not candidate.is_absolute():
if any(part == ".." for part in candidate.parts):
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
candidate = root / candidate
elif not candidate.is_absolute():
raise HTTPException(status_code=400, detail="Path must be absolute")
if ".." in candidate.parts:
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
if for_write and not candidate.exists():
parent = _canonical_path(candidate.parent)
resolved = parent / candidate.name
else:
resolved = _canonical_path(candidate, require_exists=not for_write)
if root is not None and not _path_is_under(root, resolved):
raise HTTPException(status_code=403, detail="Path outside managed files root")
return policy, resolved, str(resolved)
def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]:
locked_root = str(policy.locked_root) if policy.locked_root is not None else None
return {
"root": locked_root,
"locked_root": locked_root,
"can_change_path": policy.can_change_path,
}
def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]:
try:
resolved = target.resolve()
except (OSError, RuntimeError):
raise HTTPException(status_code=400, detail="Invalid path")
if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved):
raise HTTPException(status_code=403, detail="Path outside managed files root")
try:
st = resolved.stat()
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}")
is_dir = resolved.is_dir()
mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream")
return {
"name": target.name or resolved.name or str(resolved),
"path": str(resolved),
"is_directory": is_dir,
"size": None if is_dir else st.st_size,
"mtime": st.st_mtime,
"mime_type": mime_type,
}
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
text = (data_url or "").strip()
if not text.startswith("data:") or "," not in text:
raise HTTPException(status_code=400, detail="Upload payload must be a data URL")
header, encoded = text.split(",", 1)
mime_type = header[5:].split(";", 1)[0] or "application/octet-stream"
if ";base64" not in header:
raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded")
try:
data = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
raise HTTPException(status_code=400, detail="Upload payload is not valid base64")
if len(data) > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
return data, mime_type
@app.get("/api/files")
async def list_managed_files(request: Request, path: Optional[str] = None):
policy, target, display_path = _resolve_managed_path(path, request)
if not target.exists():
raise HTTPException(status_code=404, detail="Path not found")
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
try:
entries = [_managed_file_entry(policy, child) for child in target.iterdir()]
except PermissionError:
raise HTTPException(status_code=403, detail="Directory is not readable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}")
entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower()))
locked_root = policy.locked_root
parent = None
if target.parent != target and (locked_root is None or target != locked_root):
parent = str(target.parent)
return {
"path": display_path,
"parent": parent,
"entries": entries,
**_managed_response_meta(policy),
}
@app.get("/api/files/read")
async def read_managed_file(request: Request, path: str):
policy, target, display_path = _resolve_managed_path(path, request)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
if not target.is_file():
raise HTTPException(status_code=400, detail="Path is not a file")
try:
size = target.stat().st_size
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
if size > _MANAGED_FILE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File is too large")
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
try:
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not read file: {exc}")
return {
"name": target.name,
"path": display_path,
"size": size,
"mime_type": mime_type,
"data_url": f"data:{mime_type};base64,{encoded}",
**_managed_response_meta(policy),
}
@app.post("/api/files/upload")
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
if target.exists() and target.is_dir():
raise HTTPException(status_code=409, detail="A directory already exists at that path")
if target.exists() and not payload.overwrite:
raise HTTPException(status_code=409, detail="File already exists")
data, _mime_type = _decode_data_url(payload.data_url)
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
return {
"ok": True,
"entry": _managed_file_entry(policy, target),
"path": display_path,
**_managed_response_meta(policy),
}
@app.post("/api/files/mkdir")
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
if target.exists() and not target.is_dir():
raise HTTPException(status_code=409, detail="A file already exists at that path")
try:
target.mkdir(parents=True, exist_ok=True)
except PermissionError:
raise HTTPException(status_code=403, detail="Directory is not writable")
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}")
return {
"ok": True,
"entry": _managed_file_entry(policy, target),
"path": display_path,
**_managed_response_meta(policy),
}
@app.delete("/api/files")
async def delete_managed_file(payload: ManagedFileDelete, request: Request):
policy, target, display_path = _resolve_managed_path(payload.path, request)
if policy.locked_root is not None and target == policy.locked_root:
raise HTTPException(status_code=400, detail="Cannot delete the managed files root")
if target.parent == target:
raise HTTPException(status_code=400, detail="Cannot delete the filesystem root")
if not target.exists():
raise HTTPException(status_code=404, detail="Path not found")
try:
if target.is_dir():
if payload.recursive:
shutil.rmtree(target)
else:
target.rmdir()
else:
target.unlink()
except OSError as exc:
status_code = 409 if target.is_dir() and not payload.recursive else 500
raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}")
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()
@@ -7422,6 +7740,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 +7900,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 +8054,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

@@ -395,6 +395,38 @@ def _run_one_file(
timeout_note=f"per-file timeout on exit-4 retry {attempt}",
)
if rc == 4:
# Exit-4 survived the retries (or the file was judged absent).
# Capture filesystem forensics so a CI-only "file not found" can
# be diagnosed from the log instead of guessed at: does the file
# exist NOW, what does the parent dir hold, and is the git tree
# clean? (June 2026: a PR-added test file repeatedly hit exit 4
# on one CI shard while passing locally — these lines exist so
# the next occurrence is attributable.)
forensics = [f"--- exit-4 forensics for {file} ---"]
try:
forensics.append(f"exists={file.exists()} retries_used={attempt}")
parent = file.parent
if parent.exists():
names = sorted(p.name for p in parent.iterdir())
sibling_hint = [n for n in names if file.stem[:12] in n]
forensics.append(
f"parent={parent} entries={len(names)} "
f"similar={sibling_hint[:5]}"
)
else:
forensics.append(f"parent={parent} MISSING")
git_st = subprocess.run(
["git", "status", "--porcelain"],
cwd=repo_root, capture_output=True, text=True, timeout=10,
)
dirty = git_st.stdout.strip().splitlines()
forensics.append(f"git_dirty_entries={len(dirty)}")
forensics.extend(f" {line}" for line in dirty[:10])
except Exception as exc: # noqa: BLE001 — forensics must never mask rc=4
forensics.append(f"(forensics error: {exc})")
output = output + "\n" + "\n".join(forensics)
if rc == 5:
# No tests collected — every test in the file was filtered out.
# Treat as a pass; surface info in a slightly distinct status

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

@@ -0,0 +1,246 @@
"""Tests for the dashboard-managed file browser API."""
from types import SimpleNamespace
import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
def _client_with_app_state():
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
web_server.app.state.auth_required = False
web_server.app.state.bound_host = None
client = TestClient(web_server.app)
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
return client, prev_auth_required, prev_bound_host
def _restore_app_state(prev_auth_required, prev_bound_host):
if prev_auth_required is None:
delattr(web_server.app.state, "auth_required")
else:
web_server.app.state.auth_required = prev_auth_required
if prev_bound_host is None:
if hasattr(web_server.app.state, "bound_host"):
delattr(web_server.app.state, "bound_host")
else:
web_server.app.state.bound_host = prev_bound_host
def _close_client(client):
close = getattr(client, "close", None)
if close is not None:
close()
@pytest.fixture
def forced_files_client(monkeypatch, tmp_path):
root = tmp_path / "data"
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
client, prev_auth_required, prev_bound_host = _client_with_app_state()
try:
yield client, root
finally:
_close_client(client)
_restore_app_state(prev_auth_required, prev_bound_host)
@pytest.fixture
def local_files_client(monkeypatch, tmp_path):
home = tmp_path / "home"
home.mkdir()
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setenv("HOME", str(home))
client, prev_auth_required, prev_bound_host = _client_with_app_state()
try:
yield client, home
finally:
_close_client(client)
_restore_app_state(prev_auth_required, prev_bound_host)
def test_forced_root_file_upload_list_read_delete_roundtrip(forced_files_client):
client, root = forced_files_client
file_path = root / "out" / "hello.txt"
created = client.post(
"/api/files/upload",
json={
"path": str(file_path),
"data_url": "data:text/plain;base64,aGVsbG8=",
},
)
assert created.status_code == 200
assert created.json()["entry"]["path"] == str(file_path)
assert created.json()["locked_root"] == str(root)
assert created.json()["can_change_path"] is False
assert file_path.read_text() == "hello"
listing = client.get("/api/files", params={"path": str(root / "out")})
assert listing.status_code == 200
assert listing.json()["path"] == str(root / "out")
assert listing.json()["parent"] == str(root)
assert listing.json()["entries"] == [
{
"name": "hello.txt",
"path": str(file_path),
"is_directory": False,
"size": 5,
"mtime": pytest.approx(file_path.stat().st_mtime),
"mime_type": "text/plain",
}
]
read = client.get("/api/files/read", params={"path": str(file_path)})
assert read.status_code == 200
assert read.json()["data_url"] == "data:text/plain;base64,aGVsbG8="
deleted = client.request(
"DELETE",
"/api/files",
json={"path": str(file_path)},
)
assert deleted.status_code == 200
assert not file_path.exists()
def test_directory_management_requires_recursive_delete_for_nonempty_dirs(forced_files_client):
client, root = forced_files_client
runs_path = root / "runs"
checkpoints_path = runs_path / "checkpoints"
created = client.post("/api/files/mkdir", json={"path": str(checkpoints_path)})
assert created.status_code == 200
assert checkpoints_path.is_dir()
listing = client.get("/api/files", params={"path": str(runs_path)})
assert listing.status_code == 200
assert listing.json()["entries"][0]["path"] == str(checkpoints_path)
assert listing.json()["entries"][0]["is_directory"] is True
non_recursive = client.request(
"DELETE",
"/api/files",
json={"path": str(runs_path), "recursive": False},
)
assert non_recursive.status_code == 409
recursive = client.request(
"DELETE",
"/api/files",
json={"path": str(runs_path), "recursive": True},
)
assert recursive.status_code == 200
assert not runs_path.exists()
def test_forced_root_paths_stay_under_root(forced_files_client, tmp_path):
client, root = forced_files_client
outside = tmp_path / "outside"
outside.mkdir()
(outside / "secret.txt").write_text("do not leak")
traversal = client.get("/api/files", params={"path": "../outside"})
assert traversal.status_code == 400
outside_absolute = client.get("/api/files", params={"path": str(outside)})
assert outside_absolute.status_code == 403
root_delete = client.request(
"DELETE",
"/api/files",
json={"path": str(root), "recursive": True},
)
assert root_delete.status_code == 400
root.mkdir(exist_ok=True)
link = root / "escape"
try:
link.symlink_to(outside, target_is_directory=True)
except OSError:
pytest.skip("filesystem does not allow directory symlinks")
escaped = client.get("/api/files", params={"path": str(link)})
assert escaped.status_code == 403
def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_client, tmp_path):
client, home = local_files_client
(home / "home.txt").write_text("home")
default_listing = client.get("/api/files")
assert default_listing.status_code == 200
assert default_listing.json()["path"] == str(home)
assert default_listing.json()["locked_root"] is None
assert default_listing.json()["can_change_path"] is True
assert default_listing.json()["entries"][0]["path"] == str(home / "home.txt")
other = tmp_path / "other"
other.mkdir()
(other / "other.txt").write_text("other")
other_listing = client.get("/api/files", params={"path": str(other)})
assert other_listing.status_code == 200
assert other_listing.json()["path"] == str(other)
assert other_listing.json()["parent"] == str(tmp_path)
assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt")
def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
client, home = local_files_client
folder = home / "workspace"
file_path = folder / "note.txt"
created_folder = client.post("/api/files/mkdir", json={"path": str(folder)})
assert created_folder.status_code == 200
assert created_folder.json()["locked_root"] is None
assert created_folder.json()["can_change_path"] is True
assert folder.is_dir()
uploaded = client.post(
"/api/files/upload",
json={
"path": str(file_path),
"data_url": "data:text/plain;base64,bG9jYWw=",
},
)
assert uploaded.status_code == 200
assert file_path.read_text() == "local"
read = client.get("/api/files/read", params={"path": str(file_path)})
assert read.status_code == 200
assert read.json()["data_url"] == "data:text/plain;base64,bG9jYWw="
deleted = client.request(
"DELETE",
"/api/files",
json={"path": str(folder), "recursive": True},
)
assert deleted.status_code == 200
assert not folder.exists()
def test_hosted_policy_locks_to_opt_data(monkeypatch):
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
monkeypatch.setenv("HERMES_HOME", "/opt/data")
client, prev_auth_required, prev_bound_host = _client_with_app_state()
try:
request = SimpleNamespace(
app=web_server.app,
client=SimpleNamespace(host="127.0.0.1"),
url=SimpleNamespace(hostname="127.0.0.1"),
)
policy = web_server._managed_files_policy(request, create_root=False)
finally:
_restore_app_state(prev_auth_required, prev_bound_host)
client.close()
assert str(policy.locked_root) == "/opt/data"
assert policy.can_change_path is False

View File

@@ -26,6 +26,7 @@ import {
Database,
Download,
Eye,
FolderOpen,
FileText,
Globe,
Heart,
@@ -68,12 +69,14 @@ import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
import DocsPage from "@/pages/DocsPage";
import EnvPage from "@/pages/EnvPage";
import FilesPage from "@/pages/FilesPage";
import SessionsPage from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
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";
@@ -124,6 +127,7 @@ const CHAT_NAV_ITEM: NavItem = {
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/": RootRedirect,
"/sessions": SessionsPage,
"/files": FilesPage,
"/analytics": AnalyticsPage,
"/models": ModelsPage,
"/logs": LogsPage,
@@ -136,6 +140,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/webhooks": WebhooksPage,
"/system": SystemPage,
"/profiles": ProfilesPage,
"/profiles/new": ProfileBuilderPage,
"/config": ConfigPage,
"/env": EnvPage,
"/docs": DocsPage,
@@ -156,6 +161,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
label: "Sessions",
icon: MessageSquare,
},
{ path: "/files", label: "Files", icon: FolderOpen },
{
path: "/analytics",
labelKey: "analytics",
@@ -194,6 +200,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
Clock,
Cpu,
FileText,
FolderOpen,
KeyRound,
MessageSquare,
Package,

View File

@@ -325,6 +325,32 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ older_than_days, source }),
}),
listFiles: (path?: string) => {
const query = path ? `?path=${encodeURIComponent(path)}` : "";
return fetchJSON<ManagedFilesResponse>(`/api/files${query}`);
},
readFile: (path: string) =>
fetchJSON<ManagedFileReadResponse>(
`/api/files/read?path=${encodeURIComponent(path)}`,
),
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
}),
createDirectory: (path: string) =>
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
}),
deleteFile: (path: string, recursive = false) =>
fetchJSON<{ ok: boolean; path: string }>("/api/files", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, recursive }),
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
@@ -439,8 +465,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),
@@ -1504,6 +1541,44 @@ export interface LogsResponse {
lines: string[];
}
export interface ManagedFileEntry {
name: string;
path: string;
is_directory: boolean;
size: number | null;
mtime: number;
mime_type: string | null;
}
export interface ManagedFilesResponse {
root: string | null;
path: string;
parent: string | null;
locked_root: string | null;
can_change_path: boolean;
entries: ManagedFileEntry[];
}
export interface ManagedFileReadResponse {
name: string;
path: string;
size: number;
mime_type: string;
data_url: string;
root: string | null;
locked_root: string | null;
can_change_path: boolean;
}
export interface ManagedFileWriteResponse {
ok: boolean;
path: string;
entry: ManagedFileEntry;
root: string | null;
locked_root: string | null;
can_change_path: boolean;
}
export interface AnalyticsDailyEntry {
day: string;
input_tokens: number;

538
web/src/pages/FilesPage.tsx Normal file
View File

@@ -0,0 +1,538 @@
import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent as ReactDragEvent,
} from "react";
import {
ArrowUp,
Download,
FileIcon,
Folder,
FolderOpen,
FolderPlus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { Input } from "@nous-research/ui/ui/components/input";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { usePageHeader } from "@/contexts/usePageHeader";
import { api } from "@/lib/api";
import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api";
import { PluginSlot } from "@/plugins";
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
function joinPath(base: string, name: string): string {
const cleanName = name.trim().replace(/^[\\/]+/, "");
if (!cleanName) return base;
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`;
return `${base}${separator}${cleanName}`;
}
function formatBytes(size: number | null): string {
if (size === null) return "-";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function readAsDataUrl(file: globalThis.File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
if (typeof reader.result === "string") resolve(reader.result);
else reject(new Error("Could not read file"));
});
reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file")));
reader.readAsDataURL(file);
});
}
function downloadDataUrl(dataUrl: string, name: string) {
const link = document.createElement("a");
link.href = dataUrl;
link.download = name || "download";
document.body.appendChild(link);
link.click();
link.remove();
}
function displayPath(path: string | null | undefined): string {
return path?.trim() || "Files";
}
function transferHasFiles(event: ReactDragEvent<HTMLElement>): boolean {
return Array.from(event.dataTransfer.types).includes("Files");
}
export default function FilesPage() {
const { toast, showToast } = useToast();
const { setAfterTitle, setEnd } = usePageHeader();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const dragDepthRef = useRef(0);
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const [pathInput, setPathInput] = useState("");
const [listing, setListing] = useState<ManagedFilesResponse | null>(null);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [draggingFiles, setDraggingFiles] = useState(false);
const [creating, setCreating] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [folderName, setFolderName] = useState("");
const [pendingDelete, setPendingDelete] = useState<ManagedFileEntry | null>(null);
const [error, setError] = useState<string | null>(null);
const activePath = listing?.path ?? currentPath ?? "";
const canChangePath = listing?.can_change_path ?? false;
const canUpload = Boolean(activePath) && !uploading;
const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath);
const load = useCallback(
async (path = currentPath) => {
setLoading(true);
setError(null);
try {
const result = await api.listFiles(path);
setListing(result);
setCurrentPath(result.path);
setPathInput(result.path);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
},
[currentPath],
);
useEffect(() => {
// Existing dashboard data pages fetch from effects; keep this local and explicit
// until the shared lint profile is updated for async page loaders.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load(currentPath);
}, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setAfterTitle(
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
{headerPath}
</Badge>,
);
setEnd(
<div className="flex items-center gap-2">
<Button
ghost
size="icon"
type="button"
onClick={() => void load()}
disabled={loading}
aria-label="Refresh files"
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [headerPath, load, loading, setAfterTitle, setEnd]);
const openDirectory = (entry: ManagedFileEntry) => {
if (entry.is_directory) {
setCurrentPath(entry.path);
}
};
const goToPath = async () => {
const nextPath = pathInput.trim();
if (!nextPath) {
showToast("Path required", "error");
return;
}
await load(nextPath);
};
const createDirectory = async () => {
const name = folderName.trim();
if (!activePath) {
showToast("Directory unavailable", "error");
return;
}
if (!name) {
showToast("Folder name required", "error");
return;
}
setCreating(true);
try {
await api.createDirectory(joinPath(activePath, name));
setFolderName("");
setCreateDialogOpen(false);
showToast("Folder created", "success");
await load();
} catch (e) {
showToast(`Create failed: ${e}`, "error");
} finally {
setCreating(false);
}
};
const uploadFiles = async (files: FileList | null) => {
if (!files?.length) return;
setUploading(true);
try {
for (const file of Array.from(files)) {
const dataUrl = await readAsDataUrl(file);
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
}
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
await load();
} catch (e) {
showToast(`Upload failed: ${e}`, "error");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload || !transferHasFiles(event)) return;
event.preventDefault();
dragDepthRef.current += 1;
setDraggingFiles(true);
};
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload || !transferHasFiles(event)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload || !transferHasFiles(event)) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setDraggingFiles(false);
}
};
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
if (!canUpload) return;
event.preventDefault();
dragDepthRef.current = 0;
setDraggingFiles(false);
void uploadFiles(event.dataTransfer.files);
};
const downloadFile = async (entry: ManagedFileEntry) => {
if (entry.is_directory) return;
try {
const file = await api.readFile(entry.path);
downloadDataUrl(file.data_url, file.name);
} catch (e) {
showToast(`Download failed: ${e}`, "error");
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
setDeleting(true);
try {
await api.deleteFile(pendingDelete.path, pendingDelete.is_directory);
showToast("Deleted", "success");
setPendingDelete(null);
await load();
} catch (e) {
showToast(`Delete failed: ${e}`, "error");
} finally {
setDeleting(false);
}
};
return (
<div className="flex min-w-0 max-w-full flex-col gap-4">
<Toast toast={toast} />
<PluginSlot name="files:top" />
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => void uploadFiles(event.currentTarget.files)}
/>
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
{canChangePath ? (
<form
className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
void goToPath();
}}
>
<Input
value={pathInput}
onChange={(event) => setPathInput(event.target.value)}
aria-label="Path"
placeholder="Path"
className="h-9 min-w-0 flex-1 font-mono"
/>
<Button type="submit" size="sm" outlined className="uppercase">
Go
</Button>
</form>
) : (
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
{activePath}
</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!canUpload}
size="sm"
outlined
className="uppercase"
prefix={uploading ? <Spinner /> : <Upload />}
>
Upload
</Button>
<Button
type="button"
onClick={() => setCreateDialogOpen(true)}
disabled={!activePath}
size="sm"
outlined
className="uppercase"
prefix={<FolderPlus />}
>
Create
</Button>
</div>
</div>
<button
type="button"
onClick={() => canUpload && fileInputRef.current?.click()}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
disabled={!canUpload}
aria-label="Upload files"
className={`flex min-h-20 w-full min-w-0 items-center justify-between gap-4 border border-dashed px-4 py-3 text-left transition ${
draggingFiles
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-background/20 text-text-secondary hover:border-text-tertiary hover:bg-background/35"
} disabled:cursor-not-allowed disabled:opacity-60`}
>
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-border bg-background/45 text-text-tertiary">
{uploading ? <Spinner /> : <Upload className="h-4 w-4" />}
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold uppercase tracking-[0.08em] text-foreground">
{uploading ? "Uploading" : draggingFiles ? "Release to upload" : "Drop files here"}
</span>
<span className="block truncate font-mono text-xs text-text-secondary" title={activePath}>
{activePath || "Loading"}
</span>
</span>
</span>
<span className="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary sm:block">
Choose files
</span>
</button>
<Card className="min-w-0 max-w-full overflow-hidden">
<CardContent className="overflow-x-auto p-0">
{error && (
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
<span>Name</span>
<span>Size</span>
<span>Modified</span>
<span className="text-right">Actions</span>
</div>
{listing?.parent && (
<button
type="button"
onClick={() => setCurrentPath(listing.parent ?? undefined)}
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
>
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
..
</span>
<span />
<span />
<span />
</button>
)}
{loading && !listing ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Spinner />
Loading files...
</div>
) : listing && listing.entries.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
) : (
listing?.entries.map((entry) => (
<div
key={entry.path}
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
>
<button
type="button"
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
>
{entry.is_directory ? (
<Folder className="h-4 w-4 shrink-0 text-warning" />
) : (
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
)}
<span className="truncate">{entry.name}</span>
</button>
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
<span className="truncate text-xs text-text-secondary">
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
</span>
<span className="flex justify-end gap-1">
{entry.is_directory ? (
<Button
ghost
size="icon"
type="button"
onClick={() => openDirectory(entry)}
aria-label={`Open ${entry.name}`}
>
<FolderOpen />
</Button>
) : (
<Button
ghost
size="icon"
type="button"
onClick={() => void downloadFile(entry)}
aria-label={`Download ${entry.name}`}
>
<Download />
</Button>
)}
<Button
ghost
size="icon"
type="button"
onClick={() => setPendingDelete(entry)}
aria-label={`Delete ${entry.name}`}
className="text-destructive hover:text-destructive"
>
<Trash2 />
</Button>
</span>
</div>
))
)}
</CardContent>
</Card>
<PluginSlot name="files:bottom" />
<Dialog
open={createDialogOpen}
onOpenChange={(open) => {
if (creating) return;
setCreateDialogOpen(open);
if (!open) setFolderName("");
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Create folder</DialogTitle>
<DialogDescription>
Target: {activePath || "Loading"}
</DialogDescription>
</DialogHeader>
<div className="p-4">
<Input
autoFocus
value={folderName}
onChange={(event) => setFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void createDirectory();
}}
placeholder="Folder name"
disabled={creating}
/>
</div>
<DialogFooter>
<Button
type="button"
outlined
onClick={() => {
setCreateDialogOpen(false);
setFolderName("");
}}
disabled={creating}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void createDirectory()}
disabled={creating}
prefix={creating ? <Spinner /> : <FolderPlus />}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmDialog
open={Boolean(pendingDelete)}
loading={deleting}
onCancel={() => setPendingDelete(null)}
onConfirm={() => void confirmDelete()}
title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"}
description={
pendingDelete?.is_directory
? "This removes the folder and everything inside it."
: "This removes the file."
}
/>
</div>
);
}

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;