feat(dashboard): SKILL.md editor on Skills page + attach-skill selector in cron modals (#44231)
Headless/VPS users (dashboard-over-Tailscale, no comfortable SSH) could list/toggle/install skills and create/edit cron jobs, but not author a custom skill or link one to a cron job — the UI set WHEN a job runs, but not WHICH skill it uses. - Skills page: 'New skill' button + per-row edit pencil open a SKILL.md editor dialog (frontmatter + body, server-side validation via the same _create_skill/_edit_skill path as the agent's skill_manage tool). - New endpoints: GET /api/skills/content, POST /api/skills, PUT /api/skills/content — all profile-scoped via _profile_scope(), which now also retargets tools.skill_manager_tool's import-time SKILLS_DIR binding. - Cron page: skills multi-select in both create and edit modals (parity with hermes cron --skill / edit --add-skill); CronJobCreate gains a skills field; job cards show an attached-skills badge. update_job already accepted skills in updates. - Tests: 17 new endpoint tests (content read, create/edit validation + profile scoping + auth gate, cron skills round-trip).
This commit is contained in:
parent
f456f302df
commit
a09343cc96
6 changed files with 806 additions and 22 deletions
|
|
@ -6248,6 +6248,7 @@ class CronJobCreate(BaseModel):
|
|||
schedule: str
|
||||
name: str = ""
|
||||
deliver: str = "local"
|
||||
skills: Optional[List[str]] = None
|
||||
|
||||
|
||||
class CronJobUpdate(BaseModel):
|
||||
|
|
@ -6419,6 +6420,7 @@ async def create_cron_job(body: CronJobCreate, profile: str = "default"):
|
|||
schedule=body.schedule,
|
||||
name=body.name,
|
||||
deliver=body.deliver,
|
||||
skills=body.skills,
|
||||
)
|
||||
except Exception as e:
|
||||
_log.exception("POST /api/cron/jobs failed")
|
||||
|
|
@ -8649,36 +8651,54 @@ def _profile_scope(profile: Optional[str]):
|
|||
1. ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call
|
||||
time — the context-local override from ``set_hermes_home_override``
|
||||
reaches them (same pattern as ``_write_profile_model``).
|
||||
2. ``tools.skills_tool`` binds ``SKILLS_DIR`` at import time, so the
|
||||
override CANNOT reach it. Like ``_call_cron_for_profile`` does for
|
||||
cron's module globals, temporarily retarget it under a lock and
|
||||
restore it immediately after.
|
||||
2. ``tools.skills_tool`` and ``tools.skill_manager_tool`` bind
|
||||
``SKILLS_DIR`` at import time, so the override CANNOT reach them.
|
||||
Like ``_call_cron_for_profile`` does for cron's module globals,
|
||||
temporarily retarget both under a lock and restore them
|
||||
immediately after.
|
||||
|
||||
``profile`` of None/""/"current" means "the dashboard's own profile" —
|
||||
a no-op scope, preserving existing behavior for old clients.
|
||||
config resolution is untouched, but the skill-module globals are still
|
||||
retargeted to the *current* ``get_hermes_home()`` so writes land in the
|
||||
live home even when the import-time binding is stale (e.g. the process
|
||||
imported the modules before a HERMES_HOME override, or under test
|
||||
isolation).
|
||||
"""
|
||||
requested = (profile or "").strip()
|
||||
if not requested or requested.lower() == "current":
|
||||
yield None
|
||||
return
|
||||
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
set_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
)
|
||||
from tools import skills_tool as _skills_tool
|
||||
from tools import skill_manager_tool as _skill_mgr
|
||||
|
||||
token = None
|
||||
if not requested or requested.lower() == "current":
|
||||
profile_dir = get_hermes_home()
|
||||
else:
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
with _SKILLS_PROFILE_LOCK:
|
||||
old_home = _skills_tool.HERMES_HOME
|
||||
old_skills_dir = _skills_tool.SKILLS_DIR
|
||||
old_mgr_home = _skill_mgr.HERMES_HOME
|
||||
old_mgr_skills_dir = _skill_mgr.SKILLS_DIR
|
||||
_skills_tool.HERMES_HOME = profile_dir
|
||||
_skills_tool.SKILLS_DIR = profile_dir / "skills"
|
||||
_skill_mgr.HERMES_HOME = profile_dir
|
||||
_skill_mgr.SKILLS_DIR = profile_dir / "skills"
|
||||
try:
|
||||
yield profile_dir
|
||||
yield profile_dir if token is not None else None
|
||||
finally:
|
||||
_skills_tool.HERMES_HOME = old_home
|
||||
_skills_tool.SKILLS_DIR = old_skills_dir
|
||||
reset_hermes_home_override(token)
|
||||
_skill_mgr.HERMES_HOME = old_mgr_home
|
||||
_skill_mgr.SKILLS_DIR = old_mgr_skills_dir
|
||||
if token is not None:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
class SkillToggle(BaseModel):
|
||||
|
|
@ -8714,6 +8734,85 @@ async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
|
|||
return {"ok": True, "name": body.name, "enabled": body.enabled}
|
||||
|
||||
|
||||
class SkillCreate(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
category: Optional[str] = None
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class SkillContentUpdate(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
def _clear_skills_prompt_cache() -> None:
|
||||
"""Best-effort: invalidate the skills system-prompt snapshot after a write.
|
||||
|
||||
Mirrors what ``skill_manage`` does so a dashboard-authored skill is picked
|
||||
up by the next session without a manual cache reset.
|
||||
"""
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/skills/content")
|
||||
async def get_skill_content(name: str, profile: Optional[str] = None):
|
||||
"""Return the raw SKILL.md text for a skill, for the dashboard editor."""
|
||||
from tools.skill_manager_tool import _find_skill
|
||||
|
||||
with _profile_scope(profile):
|
||||
found = _find_skill(name)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.")
|
||||
skill_md = found["path"] / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.")
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"name": name, "content": content, "path": str(skill_md)}
|
||||
|
||||
|
||||
@app.post("/api/skills")
|
||||
async def create_skill(body: SkillCreate):
|
||||
"""Create a new custom skill (SKILL.md) from the dashboard editor.
|
||||
|
||||
Calls the same validated write path as the agent's ``skill_manage``
|
||||
tool (frontmatter validation, name/category validation, size limit,
|
||||
optional security scan) — but bypasses the agent write-approval gate:
|
||||
a write from the authenticated dashboard IS the user acting directly.
|
||||
"""
|
||||
from tools.skill_manager_tool import _create_skill
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
result = _create_skill(body.name, body.content, body.category or None)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill."))
|
||||
_clear_skills_prompt_cache()
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/skills/content")
|
||||
async def update_skill_content(body: SkillContentUpdate):
|
||||
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
|
||||
from tools.skill_manager_tool import _edit_skill
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
result = _edit_skill(body.name, body.content)
|
||||
if not result.get("success"):
|
||||
err = result.get("error", "Failed to update skill.")
|
||||
status = 404 if "not found" in str(err).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=err)
|
||||
_clear_skills_prompt_cache()
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/tools/toolsets")
|
||||
async def get_toolsets(profile: Optional[str] = None):
|
||||
from hermes_cli.tools_config import (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue