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
@ -6248,6 +6248,7 @@ class CronJobCreate(BaseModel):
|
|||||||
schedule: str
|
schedule: str
|
||||||
name: str = ""
|
name: str = ""
|
||||||
deliver: str = "local"
|
deliver: str = "local"
|
||||||
|
skills: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class CronJobUpdate(BaseModel):
|
class CronJobUpdate(BaseModel):
|
||||||
@ -6419,6 +6420,7 @@ async def create_cron_job(body: CronJobCreate, profile: str = "default"):
|
|||||||
schedule=body.schedule,
|
schedule=body.schedule,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
deliver=body.deliver,
|
deliver=body.deliver,
|
||||||
|
skills=body.skills,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.exception("POST /api/cron/jobs failed")
|
_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
|
1. ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call
|
||||||
time — the context-local override from ``set_hermes_home_override``
|
time — the context-local override from ``set_hermes_home_override``
|
||||||
reaches them (same pattern as ``_write_profile_model``).
|
reaches them (same pattern as ``_write_profile_model``).
|
||||||
2. ``tools.skills_tool`` binds ``SKILLS_DIR`` at import time, so the
|
2. ``tools.skills_tool`` and ``tools.skill_manager_tool`` bind
|
||||||
override CANNOT reach it. Like ``_call_cron_for_profile`` does for
|
``SKILLS_DIR`` at import time, so the override CANNOT reach them.
|
||||||
cron's module globals, temporarily retarget it under a lock and
|
Like ``_call_cron_for_profile`` does for cron's module globals,
|
||||||
restore it immediately after.
|
temporarily retarget both under a lock and restore them
|
||||||
|
immediately after.
|
||||||
|
|
||||||
``profile`` of None/""/"current" means "the dashboard's own profile" —
|
``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()
|
requested = (profile or "").strip()
|
||||||
if not requested or requested.lower() == "current":
|
|
||||||
yield None
|
|
||||||
return
|
|
||||||
|
|
||||||
profile_dir = _resolve_profile_dir(requested)
|
from hermes_constants import (
|
||||||
|
get_hermes_home,
|
||||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
set_hermes_home_override,
|
||||||
|
reset_hermes_home_override,
|
||||||
|
)
|
||||||
from tools import skills_tool as _skills_tool
|
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:
|
with _SKILLS_PROFILE_LOCK:
|
||||||
old_home = _skills_tool.HERMES_HOME
|
old_home = _skills_tool.HERMES_HOME
|
||||||
old_skills_dir = _skills_tool.SKILLS_DIR
|
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.HERMES_HOME = profile_dir
|
||||||
_skills_tool.SKILLS_DIR = profile_dir / "skills"
|
_skills_tool.SKILLS_DIR = profile_dir / "skills"
|
||||||
|
_skill_mgr.HERMES_HOME = profile_dir
|
||||||
|
_skill_mgr.SKILLS_DIR = profile_dir / "skills"
|
||||||
try:
|
try:
|
||||||
yield profile_dir
|
yield profile_dir if token is not None else None
|
||||||
finally:
|
finally:
|
||||||
_skills_tool.HERMES_HOME = old_home
|
_skills_tool.HERMES_HOME = old_home
|
||||||
_skills_tool.SKILLS_DIR = old_skills_dir
|
_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):
|
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}
|
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")
|
@app.get("/api/tools/toolsets")
|
||||||
async def get_toolsets(profile: Optional[str] = None):
|
async def get_toolsets(profile: Optional[str] = None):
|
||||||
from hermes_cli.tools_config import (
|
from hermes_cli.tools_config import (
|
||||||
|
|||||||
259
tests/hermes_cli/test_web_server_skill_editor.py
Normal file
259
tests/hermes_cli/test_web_server_skill_editor.py
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
"""Tests for the dashboard skill editor endpoints and cron skill attachment.
|
||||||
|
|
||||||
|
The Skills page can now create/edit custom skills (SKILL.md) and the Cron
|
||||||
|
page can attach skills to jobs — closing the "SSH + nano is the only way"
|
||||||
|
gap for headless/VPS users. These tests pin:
|
||||||
|
|
||||||
|
- GET /api/skills/content returns raw SKILL.md (and profile-scopes).
|
||||||
|
- POST /api/skills creates a skill through the same validated write path
|
||||||
|
as the agent's ``skill_manage`` tool (frontmatter validation enforced).
|
||||||
|
- PUT /api/skills/content rewrites an existing SKILL.md (404 on unknown).
|
||||||
|
- POST /api/cron/jobs accepts ``skills`` and persists it on the job;
|
||||||
|
PUT /api/cron/jobs/{id} can update the list.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
SKILL_MD = """---
|
||||||
|
name: {name}
|
||||||
|
description: a test skill
|
||||||
|
---
|
||||||
|
|
||||||
|
# {name}
|
||||||
|
|
||||||
|
Do the thing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(skills_dir, name):
|
||||||
|
d = skills_dir / name
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
(d / "SKILL.md").write_text(SKILL_MD.format(name=name), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||||
|
"""Isolated default home + one named profile, each with its own skills."""
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
from hermes_cli import profiles
|
||||||
|
|
||||||
|
default_home = get_hermes_home()
|
||||||
|
profiles_root = default_home / "profiles"
|
||||||
|
worker_home = profiles_root / "worker_alpha"
|
||||||
|
for home in (default_home, worker_home):
|
||||||
|
(home / "skills").mkdir(parents=True, exist_ok=True)
|
||||||
|
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
_write_skill(default_home / "skills", "dashboard-skill")
|
||||||
|
_write_skill(worker_home / "skills", "worker-skill")
|
||||||
|
|
||||||
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||||
|
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||||
|
return {"default": default_home, "worker_alpha": worker_home}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, isolated_profiles):
|
||||||
|
try:
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("fastapi/starlette not installed")
|
||||||
|
|
||||||
|
import hermes_state
|
||||||
|
from hermes_constants import get_hermes_home
|
||||||
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||||
|
|
||||||
|
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||||
|
c = TestClient(app)
|
||||||
|
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillContent:
|
||||||
|
def test_get_content_returns_raw_skill_md(self, client, isolated_profiles):
|
||||||
|
resp = client.get("/api/skills/content", params={"name": "dashboard-skill"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "dashboard-skill"
|
||||||
|
assert data["content"].startswith("---")
|
||||||
|
assert "Do the thing." in data["content"]
|
||||||
|
|
||||||
|
def test_get_content_scopes_to_profile(self, client, isolated_profiles):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/skills/content",
|
||||||
|
params={"name": "worker-skill", "profile": "worker_alpha"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# ...and the worker skill is invisible without the profile param.
|
||||||
|
resp = client.get("/api/skills/content", params={"name": "worker-skill"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_get_content_unknown_skill_404(self, client, isolated_profiles):
|
||||||
|
resp = client.get("/api/skills/content", params={"name": "nope"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillCreate:
|
||||||
|
def test_create_writes_skill_md(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/skills",
|
||||||
|
json={"name": "my-new-skill", "content": SKILL_MD.format(name="my-new-skill")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
skill_md = isolated_profiles["default"] / "skills" / "my-new-skill" / "SKILL.md"
|
||||||
|
assert skill_md.exists()
|
||||||
|
assert "Do the thing." in skill_md.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_create_with_category(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/skills",
|
||||||
|
json={
|
||||||
|
"name": "cat-skill",
|
||||||
|
"category": "devops",
|
||||||
|
"content": SKILL_MD.format(name="cat-skill"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert (
|
||||||
|
isolated_profiles["default"] / "skills" / "devops" / "cat-skill" / "SKILL.md"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_create_scopes_to_profile(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/skills",
|
||||||
|
json={
|
||||||
|
"name": "worker-new",
|
||||||
|
"content": SKILL_MD.format(name="worker-new"),
|
||||||
|
"profile": "worker_alpha",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert (
|
||||||
|
isolated_profiles["worker_alpha"] / "skills" / "worker-new" / "SKILL.md"
|
||||||
|
).exists()
|
||||||
|
# Dashboard's own skills dir stays clean.
|
||||||
|
assert not (
|
||||||
|
isolated_profiles["default"] / "skills" / "worker-new"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
def test_create_rejects_missing_frontmatter(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/skills",
|
||||||
|
json={"name": "bad-skill", "content": "no frontmatter here"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "frontmatter" in resp.json()["detail"].lower()
|
||||||
|
assert not (isolated_profiles["default"] / "skills" / "bad-skill").exists()
|
||||||
|
|
||||||
|
def test_create_rejects_duplicate_name(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/skills",
|
||||||
|
json={
|
||||||
|
"name": "dashboard-skill",
|
||||||
|
"content": SKILL_MD.format(name="dashboard-skill"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "already exists" in resp.json()["detail"]
|
||||||
|
|
||||||
|
def test_create_rejects_invalid_name(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/skills",
|
||||||
|
json={"name": "../escape", "content": SKILL_MD.format(name="x")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillUpdate:
|
||||||
|
def test_update_rewrites_skill_md(self, client, isolated_profiles):
|
||||||
|
new_content = SKILL_MD.format(name="dashboard-skill").replace(
|
||||||
|
"Do the thing.", "Do the NEW thing."
|
||||||
|
)
|
||||||
|
resp = client.put(
|
||||||
|
"/api/skills/content",
|
||||||
|
json={"name": "dashboard-skill", "content": new_content},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
skill_md = (
|
||||||
|
isolated_profiles["default"] / "skills" / "dashboard-skill" / "SKILL.md"
|
||||||
|
)
|
||||||
|
assert "Do the NEW thing." in skill_md.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_update_unknown_skill_404(self, client, isolated_profiles):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/skills/content",
|
||||||
|
json={"name": "nope", "content": SKILL_MD.format(name="nope")},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_update_invalid_frontmatter_400(self, client, isolated_profiles):
|
||||||
|
resp = client.put(
|
||||||
|
"/api/skills/content",
|
||||||
|
json={"name": "dashboard-skill", "content": "broken"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditorEndpointsAuth:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"method,path,kwargs",
|
||||||
|
[
|
||||||
|
("get", "/api/skills/content?name=dashboard-skill", {}),
|
||||||
|
("post", "/api/skills", {"json": {"name": "x", "content": "y"}}),
|
||||||
|
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_endpoints_401_without_token(
|
||||||
|
self, client, isolated_profiles, method, path, kwargs
|
||||||
|
):
|
||||||
|
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||||
|
|
||||||
|
client.headers.pop(_SESSION_HEADER_NAME, None)
|
||||||
|
resp = getattr(client, method)(path, **kwargs)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronJobSkills:
|
||||||
|
def test_create_job_with_skills(self, client, isolated_profiles):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/cron/jobs",
|
||||||
|
json={
|
||||||
|
"prompt": "do work",
|
||||||
|
"schedule": "every 1h",
|
||||||
|
"name": "skilled-job",
|
||||||
|
"skills": ["dashboard-skill"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
job = resp.json()
|
||||||
|
assert job["skills"] == ["dashboard-skill"]
|
||||||
|
|
||||||
|
# Round-trip: the list endpoint carries the skills field too.
|
||||||
|
listed = client.get("/api/cron/jobs", params={"profile": "default"}).json()
|
||||||
|
match = [j for j in listed if j["id"] == job["id"]]
|
||||||
|
assert match and match[0]["skills"] == ["dashboard-skill"]
|
||||||
|
|
||||||
|
def test_update_job_skills(self, client, isolated_profiles):
|
||||||
|
job = client.post(
|
||||||
|
"/api/cron/jobs",
|
||||||
|
json={"prompt": "do work", "schedule": "every 1h"},
|
||||||
|
).json()
|
||||||
|
assert job.get("skills") in (None, [])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/cron/jobs/{job['id']}",
|
||||||
|
json={"updates": {"skills": ["dashboard-skill", "worker-skill"]}},
|
||||||
|
params={"profile": "default"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["skills"] == ["dashboard-skill", "worker-skill"]
|
||||||
|
|
||||||
|
# Clearing works too.
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/cron/jobs/{job['id']}",
|
||||||
|
json={"updates": {"skills": []}},
|
||||||
|
params={"profile": "default"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["skills"] == []
|
||||||
215
web/src/components/SkillEditorDialog.tsx
Normal file
215
web/src/components/SkillEditorDialog.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
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 { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@nous-research/ui/ui/components/dialog";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
|
||||||
|
/* */
|
||||||
|
/* Headless/VPS users have no editor besides this: the only other way */
|
||||||
|
/* to author a custom skill is SSH + a terminal editor. Create mode */
|
||||||
|
/* posts a brand-new skill (name + optional category + SKILL.md); */
|
||||||
|
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
|
||||||
|
/* Validation (frontmatter, name, size) happens server-side via the */
|
||||||
|
/* same path the agent's skill_manage tool uses, so errors come back */
|
||||||
|
/* as actionable messages rendered inline. */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const CREATE_TEMPLATE = `---
|
||||||
|
name: my-skill
|
||||||
|
description: One-line description of when to use this skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# My Skill
|
||||||
|
|
||||||
|
Numbered steps, exact commands, and pitfalls go here.
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface SkillEditorDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
/** Skill name to edit, or null for create mode. */
|
||||||
|
editName: string | null;
|
||||||
|
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
|
||||||
|
profile?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called after a successful save so the page can refresh its list. */
|
||||||
|
onSaved: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillEditorDialog({
|
||||||
|
open,
|
||||||
|
editName,
|
||||||
|
profile,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: SkillEditorDialogProps) {
|
||||||
|
// The body is remounted via `key` every time the dialog opens or the
|
||||||
|
// target skill changes, so all form state initializes through useState
|
||||||
|
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
{open && (
|
||||||
|
<EditorBody
|
||||||
|
key={editName ?? "__create__"}
|
||||||
|
editName={editName}
|
||||||
|
profile={profile}
|
||||||
|
onClose={onClose}
|
||||||
|
onSaved={onSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorBody({
|
||||||
|
editName,
|
||||||
|
profile,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: Omit<SkillEditorDialogProps, "open">) {
|
||||||
|
const isEdit = editName !== null;
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editName) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.getSkillContent(editName, profile || undefined)
|
||||||
|
.then((res) => !cancelled && setContent(res.content))
|
||||||
|
.catch((e) => !cancelled && setError(String(e)))
|
||||||
|
.finally(() => !cancelled && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [editName, profile]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!isEdit && !name.trim()) {
|
||||||
|
setError("Skill name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!content.trim()) {
|
||||||
|
setError("SKILL.md content is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await api.updateSkillContent(editName, content, profile || undefined);
|
||||||
|
onSaved(editName);
|
||||||
|
} else {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
await api.createSkill(
|
||||||
|
{
|
||||||
|
name: trimmed,
|
||||||
|
content,
|
||||||
|
category: category.trim() || undefined,
|
||||||
|
},
|
||||||
|
profile || undefined,
|
||||||
|
);
|
||||||
|
onSaved(trimmed);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{isEdit ? `Edit skill: ${editName}` : "New skill"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
|
||||||
|
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="skill-editor-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="skill-editor-name"
|
||||||
|
autoFocus
|
||||||
|
placeholder="my-skill"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="skill-editor-category">Category (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="skill-editor-category"
|
||||||
|
placeholder="devops"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="skill-editor-content">SKILL.md</Label>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Spinner className="text-xl text-primary" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
id="skill-editor-content"
|
||||||
|
spellCheck={false}
|
||||||
|
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="whitespace-pre-wrap text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button ghost size="sm" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="uppercase"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading}
|
||||||
|
prefix={saving ? <Spinner /> : undefined}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -469,7 +469,7 @@ export const api = {
|
|||||||
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
||||||
getCronDeliveryTargets: () =>
|
getCronDeliveryTargets: () =>
|
||||||
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
|
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
|
||||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
|
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string; skills?: string[] }, profile = "default") =>
|
||||||
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@ -479,7 +479,7 @@ export const api = {
|
|||||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||||
updateCronJob: (
|
updateCronJob: (
|
||||||
id: string,
|
id: string,
|
||||||
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
|
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] },
|
||||||
profile = "default",
|
profile = "default",
|
||||||
) =>
|
) =>
|
||||||
fetchJSON<CronJob>(
|
fetchJSON<CronJob>(
|
||||||
@ -605,6 +605,22 @@ export const api = {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
|
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
|
||||||
}),
|
}),
|
||||||
|
getSkillContent: (name: string, profile?: string) =>
|
||||||
|
fetchJSON<SkillContent>(
|
||||||
|
`/api/skills/content?name=${encodeURIComponent(name)}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
|
||||||
|
),
|
||||||
|
createSkill: (skill: { name: string; content: string; category?: string }, profile?: string) =>
|
||||||
|
fetchJSON<SkillWriteResult>("/api/skills", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...skill, profile: profile || undefined }),
|
||||||
|
}),
|
||||||
|
updateSkillContent: (name: string, content: string, profile?: string) =>
|
||||||
|
fetchJSON<SkillWriteResult>("/api/skills/content", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, content, profile: profile || undefined }),
|
||||||
|
}),
|
||||||
getToolsets: (profile?: string) =>
|
getToolsets: (profile?: string) =>
|
||||||
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
|
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
|
||||||
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
|
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
|
||||||
@ -1791,6 +1807,7 @@ export interface CronJob {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
prompt?: string | null;
|
prompt?: string | null;
|
||||||
script?: string | null;
|
script?: string | null;
|
||||||
|
skills?: string[] | null;
|
||||||
schedule?: { kind?: string; expr?: string; display?: string };
|
schedule?: { kind?: string; expr?: string; display?: string };
|
||||||
schedule_display?: string | null;
|
schedule_display?: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -1815,6 +1832,19 @@ export interface SkillInfo {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillContent {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillWriteResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolsetInfo {
|
export interface ToolsetInfo {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
|||||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { CronJob, CronDeliveryTarget, ProfileInfo } from "@/lib/api";
|
import type { CronJob, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api";
|
||||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SCHEDULE_STATE,
|
DEFAULT_SCHEDULE_STATE,
|
||||||
@ -51,6 +51,63 @@ function getJobPrompt(job: CronJob): string {
|
|||||||
return asText(job.prompt);
|
return asText(job.prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact multi-select for attaching skills to a cron job.
|
||||||
|
*
|
||||||
|
* A checkbox list (native inputs — the `onValueChange` rule is Select-only)
|
||||||
|
* capped to a scrollable box. Skills already on the job but missing from the
|
||||||
|
* available list (e.g. removed from disk, or the job was created via CLI in
|
||||||
|
* another profile) are still rendered so saving doesn't silently drop them.
|
||||||
|
*/
|
||||||
|
function SkillsPicker({
|
||||||
|
id,
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
emptyLabel,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
available: SkillInfo[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (skills: string[]) => void;
|
||||||
|
emptyLabel: string;
|
||||||
|
}) {
|
||||||
|
const names = available.map((s) => s.name);
|
||||||
|
const orphaned = selected.filter((s) => !names.includes(s));
|
||||||
|
const all = [...orphaned.map((name) => ({ name, description: "" })), ...available];
|
||||||
|
|
||||||
|
if (all.length === 0) {
|
||||||
|
return <p className="text-xs text-muted-foreground">{emptyLabel}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (name: string, checked: boolean) => {
|
||||||
|
if (checked) onChange([...selected, name]);
|
||||||
|
else onChange(selected.filter((s) => s !== name));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1"
|
||||||
|
>
|
||||||
|
{all.map((skill) => (
|
||||||
|
<label
|
||||||
|
key={skill.name}
|
||||||
|
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
|
||||||
|
title={skill.description || undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-foreground"
|
||||||
|
checked={selected.includes(skill.name)}
|
||||||
|
onChange={(e) => toggle(skill.name, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="font-mono-ui truncate">{skill.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getJobName(job: CronJob): string {
|
function getJobName(job: CronJob): string {
|
||||||
return asText(job.name).trim();
|
return asText(job.name).trim();
|
||||||
}
|
}
|
||||||
@ -157,6 +214,7 @@ export default function CronPage() {
|
|||||||
onClose: closeCreateModal,
|
onClose: closeCreateModal,
|
||||||
});
|
});
|
||||||
const [deliver, setDeliver] = useState("local");
|
const [deliver, setDeliver] = useState("local");
|
||||||
|
const [jobSkills, setJobSkills] = useState<string[]>([]);
|
||||||
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
|
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
|
||||||
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
|
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
|
||||||
]);
|
]);
|
||||||
@ -169,6 +227,7 @@ export default function CronPage() {
|
|||||||
const [editSchedule, setEditSchedule] = useState("");
|
const [editSchedule, setEditSchedule] = useState("");
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editDeliver, setEditDeliver] = useState("local");
|
const [editDeliver, setEditDeliver] = useState("local");
|
||||||
|
const [editSkills, setEditSkills] = useState<string[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const closeEditModal = useCallback(() => setEditJob(null), []);
|
const closeEditModal = useCallback(() => setEditJob(null), []);
|
||||||
const editModalRef = useModalBehavior({
|
const editModalRef = useModalBehavior({
|
||||||
@ -176,6 +235,12 @@ export default function CronPage() {
|
|||||||
onClose: closeEditModal,
|
onClose: closeEditModal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Skills installed in the profile a job will run under, for the
|
||||||
|
// attach-skill selector (parity with `hermes cron edit --add-skill`).
|
||||||
|
// Keyed on the create-modal profile; the edit modal reuses the list —
|
||||||
|
// a job's current skills are always shown even if not in it.
|
||||||
|
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
|
||||||
|
|
||||||
const openEditModal = useCallback((job: CronJob) => {
|
const openEditModal = useCallback((job: CronJob) => {
|
||||||
setEditJob(job);
|
setEditJob(job);
|
||||||
setEditPrompt(getJobPrompt(job));
|
setEditPrompt(getJobPrompt(job));
|
||||||
@ -184,6 +249,7 @@ export default function CronPage() {
|
|||||||
);
|
);
|
||||||
setEditName(getJobName(job));
|
setEditName(getJobName(job));
|
||||||
setEditDeliver(asText(job.deliver) || "local");
|
setEditDeliver(asText(job.deliver) || "local");
|
||||||
|
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadJobs = useCallback(() => {
|
const loadJobs = useCallback(() => {
|
||||||
@ -217,6 +283,25 @@ export default function CronPage() {
|
|||||||
loadJobs();
|
loadJobs();
|
||||||
}, [loadJobs]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
|
// Load installed skills for the profile new jobs will be created under.
|
||||||
|
// "" / "default" maps to the dashboard's own profile via the optional
|
||||||
|
// ?profile= scoping on /api/skills.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api
|
||||||
|
.getSkills(createProfile === "default" ? undefined : createProfile)
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setAvailableSkills(
|
||||||
|
[...s].sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => !cancelled && setAvailableSkills([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [createProfile]);
|
||||||
|
|
||||||
const scheduleString = buildScheduleString(scheduleState);
|
const scheduleString = buildScheduleString(scheduleState);
|
||||||
|
|
||||||
// Label for a delivery option. Configured platforms missing their cron home
|
// Label for a delivery option. Configured platforms missing their cron home
|
||||||
@ -284,6 +369,7 @@ export default function CronPage() {
|
|||||||
schedule: scheduleString,
|
schedule: scheduleString,
|
||||||
name: name.trim() || undefined,
|
name: name.trim() || undefined,
|
||||||
deliver,
|
deliver,
|
||||||
|
skills: jobSkills.length > 0 ? jobSkills : undefined,
|
||||||
},
|
},
|
||||||
createProfile,
|
createProfile,
|
||||||
);
|
);
|
||||||
@ -292,6 +378,7 @@ export default function CronPage() {
|
|||||||
setScheduleState(DEFAULT_SCHEDULE_STATE);
|
setScheduleState(DEFAULT_SCHEDULE_STATE);
|
||||||
setName("");
|
setName("");
|
||||||
setDeliver("local");
|
setDeliver("local");
|
||||||
|
setJobSkills([]);
|
||||||
setCreateModalOpen(false);
|
setCreateModalOpen(false);
|
||||||
loadJobs();
|
loadJobs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -316,6 +403,7 @@ export default function CronPage() {
|
|||||||
schedule: editSchedule.trim(),
|
schedule: editSchedule.trim(),
|
||||||
name: editName.trim(),
|
name: editName.trim(),
|
||||||
deliver: editDeliver,
|
deliver: editDeliver,
|
||||||
|
skills: editSkills,
|
||||||
},
|
},
|
||||||
getJobProfile(editJob),
|
getJobProfile(editJob),
|
||||||
);
|
);
|
||||||
@ -524,6 +612,21 @@ export default function CronPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="cron-skills">Skills (optional)</Label>
|
||||||
|
<SkillsPicker
|
||||||
|
id="cron-skills"
|
||||||
|
available={availableSkills}
|
||||||
|
selected={jobSkills}
|
||||||
|
onChange={setJobSkills}
|
||||||
|
emptyLabel="No skills installed for this profile."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Selected skills are loaded before the prompt runs — the cron
|
||||||
|
sets when, the skill sets how.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
className="uppercase"
|
className="uppercase"
|
||||||
@ -616,6 +719,17 @@ export default function CronPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-cron-skills">Skills</Label>
|
||||||
|
<SkillsPicker
|
||||||
|
id="edit-cron-skills"
|
||||||
|
available={availableSkills}
|
||||||
|
selected={editSkills}
|
||||||
|
onChange={setEditSkills}
|
||||||
|
emptyLabel="No skills installed for this profile."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
className="uppercase"
|
className="uppercase"
|
||||||
@ -691,6 +805,13 @@ export default function CronPage() {
|
|||||||
{deliver && deliver !== "local" && (
|
{deliver && deliver !== "local" && (
|
||||||
<Badge tone="outline">{deliver}</Badge>
|
<Badge tone="outline">{deliver}</Badge>
|
||||||
)}
|
)}
|
||||||
|
{Array.isArray(job.skills) && job.skills.length > 0 && (
|
||||||
|
<Badge tone="outline" title={job.skills.join(", ")}>
|
||||||
|
{job.skills.length === 1
|
||||||
|
? job.skills[0]
|
||||||
|
: `${job.skills.length} skills`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasName && promptText && (
|
{hasName && promptText && (
|
||||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
@ -38,6 +40,7 @@ import type {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||||
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
||||||
|
import { SkillEditorDialog } from "@/components/SkillEditorDialog";
|
||||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||||
@ -130,6 +133,9 @@ export default function SkillsPage() {
|
|||||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||||
const [configToolset, setConfigToolset] = useState<ToolsetInfo | null>(null);
|
const [configToolset, setConfigToolset] = useState<ToolsetInfo | null>(null);
|
||||||
|
// Skill editor dialog: open + which skill is being edited (null = create).
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editorSkill, setEditorSkill] = useState<string | null>(null);
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { setAfterTitle, setEnd } = usePageHeader();
|
const { setAfterTitle, setEnd } = usePageHeader();
|
||||||
@ -201,6 +207,28 @@ export default function SkillsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---- Skill editor (create / edit SKILL.md) ---- */
|
||||||
|
const openCreateEditor = useCallback(() => {
|
||||||
|
setEditorSkill(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}, []);
|
||||||
|
const openEditEditor = useCallback((skillName: string) => {
|
||||||
|
setEditorSkill(skillName);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}, []);
|
||||||
|
const handleEditorSaved = useCallback(
|
||||||
|
(skillName: string) => {
|
||||||
|
showToast(`${skillName} saved ✓`, "success");
|
||||||
|
// Reload the list so a newly created skill (or an edited description)
|
||||||
|
// shows up immediately.
|
||||||
|
api
|
||||||
|
.getSkills(selectedProfile || undefined)
|
||||||
|
.then(setSkills)
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
[selectedProfile, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
/* ---- Derived data ---- */
|
/* ---- Derived data ---- */
|
||||||
const lowerSearch = search.toLowerCase();
|
const lowerSearch = search.toLowerCase();
|
||||||
const isSearching = search.trim().length > 0;
|
const isSearching = search.trim().length > 0;
|
||||||
@ -436,6 +464,7 @@ export default function SkillsPage() {
|
|||||||
skill={skill}
|
skill={skill}
|
||||||
toggling={togglingSkills.has(skill.name)}
|
toggling={togglingSkills.has(skill.name)}
|
||||||
onToggle={() => handleToggleSkill(skill)}
|
onToggle={() => handleToggleSkill(skill)}
|
||||||
|
onEdit={() => openEditEditor(skill.name)}
|
||||||
noDescriptionLabel={t.skills.noDescription}
|
noDescriptionLabel={t.skills.noDescription}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -457,11 +486,22 @@ export default function SkillsPage() {
|
|||||||
)
|
)
|
||||||
: t.skills.all}
|
: t.skills.all}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge tone="secondary" className="text-xs">
|
<div className="flex items-center gap-2">
|
||||||
{t.skills.skillCount
|
<Badge tone="secondary" className="text-xs">
|
||||||
.replace("{count}", String(activeSkills.length))
|
{t.skills.skillCount
|
||||||
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
.replace("{count}", String(activeSkills.length))
|
||||||
</Badge>
|
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
outlined
|
||||||
|
className="uppercase"
|
||||||
|
onClick={openCreateEditor}
|
||||||
|
prefix={<Plus />}
|
||||||
|
>
|
||||||
|
New skill
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
@ -479,6 +519,7 @@ export default function SkillsPage() {
|
|||||||
skill={skill}
|
skill={skill}
|
||||||
toggling={togglingSkills.has(skill.name)}
|
toggling={togglingSkills.has(skill.name)}
|
||||||
onToggle={() => handleToggleSkill(skill)}
|
onToggle={() => handleToggleSkill(skill)}
|
||||||
|
onEdit={() => openEditEditor(skill.name)}
|
||||||
noDescriptionLabel={t.skills.noDescription}
|
noDescriptionLabel={t.skills.noDescription}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -583,6 +624,13 @@ export default function SkillsPage() {
|
|||||||
onChanged={() => void refreshToolsets()}
|
onChanged={() => void refreshToolsets()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SkillEditorDialog
|
||||||
|
open={editorOpen}
|
||||||
|
editName={editorSkill}
|
||||||
|
profile={selectedProfile || undefined}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSaved={handleEditorSaved}
|
||||||
|
/>
|
||||||
<PluginSlot name="skills:bottom" />
|
<PluginSlot name="skills:bottom" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -592,6 +640,7 @@ function SkillRow({
|
|||||||
skill,
|
skill,
|
||||||
toggling,
|
toggling,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onEdit,
|
||||||
noDescriptionLabel,
|
noDescriptionLabel,
|
||||||
}: SkillRowProps) {
|
}: SkillRowProps) {
|
||||||
return (
|
return (
|
||||||
@ -617,6 +666,16 @@ function SkillRow({
|
|||||||
{skill.description || noDescriptionLabel}
|
{skill.description || noDescriptionLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground"
|
||||||
|
title="Edit SKILL.md"
|
||||||
|
aria-label={`Edit ${skill.name}`}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -648,6 +707,7 @@ interface PanelItemProps {
|
|||||||
interface SkillRowProps {
|
interface SkillRowProps {
|
||||||
noDescriptionLabel: string;
|
noDescriptionLabel: string;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
skill: SkillInfo;
|
skill: SkillInfo;
|
||||||
toggling: boolean;
|
toggling: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user