diff --git a/dashboard/api.js b/dashboard/api.js index 17a9704..a54e4ad 100644 --- a/dashboard/api.js +++ b/dashboard/api.js @@ -34,6 +34,11 @@ const api = { getSkill: (name) => api.get(`/api/skills/${encodeURIComponent(name)}`), runSkill: (name, input = '', agent = 'auto') => api.post(`/api/skills/${encodeURIComponent(name)}/run`, { input, agent }), getSkillEval: (name) => api.get(`/api/skills/${encodeURIComponent(name)}/eval`), + createSkill: (name, skillMd) => api.post('/api/skills', { name, skill_md: skillMd }), + updateSkill: (name, skillMd) => api.put(`/api/skills/${encodeURIComponent(name)}`, { skill_md: skillMd }), + getSkillContextFile: (name, filename) => api.get(`/api/skills/${encodeURIComponent(name)}/context/${encodeURIComponent(filename)}`), + putSkillContextFile: (name, filename, content) => api.put(`/api/skills/${encodeURIComponent(name)}/context/${encodeURIComponent(filename)}`, { content }), + deleteSkillContextFile: (name, filename) => api.del(`/api/skills/${encodeURIComponent(name)}/context/${encodeURIComponent(filename)}`), getJobs: () => api.get('/api/scheduler/jobs'), createJob: (job) => api.post('/api/scheduler/jobs', job), deleteJob: (id) => api.del(`/api/scheduler/jobs/${encodeURIComponent(id)}`), diff --git a/dashboard/pages/skills.js b/dashboard/pages/skills.js index 42e719f..84c6122 100644 --- a/dashboard/pages/skills.js +++ b/dashboard/pages/skills.js @@ -8,6 +8,7 @@ async function renderSkills() {
+
@@ -93,6 +94,8 @@ async function showSkillDetail(name) { const lastScore = scores.length > 0 ? scores[scores.length - 1] : null; const avg = lastScore && lastScore.criteria_scores ? (lastScore.criteria_scores.reduce((a, b) => a + b, 0) / lastScore.criteria_scores.length) : null; + window._currentSkillName = name; + detail.innerHTML = `
@@ -100,8 +103,11 @@ async function showSkillDetail(name) {
-
📄 SKILL.md
-
${escapeHtml(skill.skill || 'No SKILL.md')}
+
+ 📄 SKILL.md + +
+
${escapeHtml(skill.skill || 'No SKILL.md')}
📖 Learnings
@@ -120,10 +126,20 @@ async function showSkillDetail(name) { ` : '
No evaluation scores yet
'}
-
📁 Context Files
- ${skill.context && skill.context.length > 0 - ? `
${skill.context.map(f => `${f}`).join('')}
` - : '
No context files
'} +
+ 📁 Context Files + +
+
+ ${skill.context && skill.context.length > 0 + ? skill.context.map(f => ` +
+ ${escapeHtml(f)} + + +
`).join('') + : '
No context files
'} +
${skill.eval && skill.eval.criteria ? `
Eval Criteria:
${skill.eval.criteria.map(c => `${c}`).join('')}
` : ''}
@@ -195,3 +211,125 @@ async function executeSkillRun(name) { if (runBtn) { runBtn.textContent = '▶ Run'; runBtn.disabled = false; } } } + +function showAddSkill() { + showModal('New Skill', ` +
+ + +
Letters, numbers, dashes and underscores only.
+
+
+ + +
+ `, ` + + + `); +} + +async function submitNewSkill() { + const name = document.getElementById('newSkillName').value.trim(); + const skillMd = document.getElementById('newSkillMd').value; + if (!name) { showToast('Skill name is required', 'error'); return; } + try { + await api.createSkill(name, skillMd); + showToast('Skill created!', 'success'); + closeModal(); + await renderSkills(); + showSkillDetail(name); + } catch (err) { + showToast('Failed to create skill: ' + err.message, 'error'); + } +} + +function editSkillMd(name) { + const view = document.getElementById('skillMdView'); + const current = view ? view.textContent : ''; + view.outerHTML = ` + +
+ + +
+ `; +} + +async function saveSkillMd(name) { + const content = document.getElementById('skillMdEdit').value; + try { + await api.updateSkill(name, content); + showToast('SKILL.md saved', 'success'); + showSkillDetail(name); + } catch (err) { + showToast('Failed to save: ' + err.message, 'error'); + } +} + +function addSkillContextFile(name) { + showModal('Add Context File', ` +
+ + +
+
+ + +
+ `, ` + + + `); +} + +async function submitNewContextFile(name) { + const filename = document.getElementById('newContextFilename').value.trim(); + const content = document.getElementById('newContextContent').value; + if (!filename) { showToast('File name is required', 'error'); return; } + try { + await api.putSkillContextFile(name, filename, content); + showToast('Context file added', 'success'); + closeModal(); + showSkillDetail(name); + } catch (err) { + showToast('Failed to add file: ' + err.message, 'error'); + } +} + +async function editSkillContextFile(name, filename) { + try { + const file = await api.getSkillContextFile(name, filename); + showModal(`Edit: ${filename}`, ` + + `, ` + + + `); + } catch (err) { + showToast('Failed to load file: ' + err.message, 'error'); + } +} + +async function saveSkillContextFile(name, filename) { + const content = document.getElementById('editContextContent').value; + try { + await api.putSkillContextFile(name, filename, content); + showToast('File saved', 'success'); + closeModal(); + showSkillDetail(name); + } catch (err) { + showToast('Failed to save: ' + err.message, 'error'); + } +} + +async function deleteSkillContextFile(name, filename) { + if (!confirm(`Delete "${filename}"?`)) return; + try { + await api.deleteSkillContextFile(name, filename); + showToast('File deleted', 'info'); + showSkillDetail(name); + } catch (err) { + showToast('Failed to delete: ' + err.message, 'error'); + } +} diff --git a/server.py b/server.py index b68d2a3..581a4e3 100644 --- a/server.py +++ b/server.py @@ -88,6 +88,16 @@ class SkillRunRequest(BaseModel): input: Optional[str] = "" agent: Optional[str] = "auto" +class SkillCreate(BaseModel): + name: str + skill_md: str = "" + +class SkillUpdate(BaseModel): + skill_md: str + +class SkillContextFileWrite(BaseModel): + content: str = "" + class ScheduleJobRequest(BaseModel): name: str skill: str @@ -212,6 +222,27 @@ def update_brain_file(file_name: str, data: BrainUpdate): # ─── Routes: Skills ─────────────────────────────────────────────── +SKILL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") +SKILL_CONTEXT_FILENAME_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$") + +def skill_dir_path(name: str) -> Path: + if not SKILL_NAME_RE.fullmatch(name or ""): + raise HTTPException(400, "Invalid skill name") + base = (BASE_DIR / "skills").resolve() + candidate = (base / name).resolve() + if candidate.parent != base: + raise HTTPException(400, "Invalid skill name") + return candidate + +def skill_context_file_path(name: str, filename: str) -> Path: + if not SKILL_CONTEXT_FILENAME_RE.fullmatch(filename or ""): + raise HTTPException(400, "Invalid file name") + context_dir = (skill_dir_path(name) / "context").resolve() + candidate = (context_dir / filename).resolve() + if candidate.parent != context_dir: + raise HTTPException(400, "Invalid file name") + return candidate + @app.get("/api/skills") def list_skills(): skills = [] @@ -238,7 +269,7 @@ def list_skills(): @app.get("/api/skills/{name}") def get_skill(name: str): - path = BASE_DIR / "skills" / name + path = skill_dir_path(name) if not path.exists(): raise HTTPException(404, "Skill not found") return { @@ -250,9 +281,55 @@ def get_skill(name: str): "context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [], } +@app.post("/api/skills") +def create_skill(data: SkillCreate): + path = skill_dir_path(data.name) + if path.exists(): + raise HTTPException(409, "Skill already exists") + path.mkdir(parents=True) + (path / "SKILL.md").write_text(data.skill_md, encoding="utf-8") + append_audit({"action": "skill_created", "skill": data.name}) + return {"name": data.name} + +@app.put("/api/skills/{name}") +def update_skill(name: str, data: SkillUpdate): + path = skill_dir_path(name) + if not path.exists(): + raise HTTPException(404, "Skill not found") + (path / "SKILL.md").write_text(data.skill_md, encoding="utf-8") + append_audit({"action": "skill_updated", "skill": name}) + return {"status": "ok"} + +@app.get("/api/skills/{name}/context/{filename}") +def get_skill_context_file(name: str, filename: str): + path = skill_context_file_path(name, filename) + if not path.exists(): + raise HTTPException(404, "File not found") + return {"filename": filename, "content": read_file(path)} + +@app.put("/api/skills/{name}/context/{filename}") +def put_skill_context_file(name: str, filename: str, data: SkillContextFileWrite): + skill_path = skill_dir_path(name) + if not skill_path.exists(): + raise HTTPException(404, "Skill not found") + path = skill_context_file_path(name, filename) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(data.content, encoding="utf-8") + append_audit({"action": "skill_context_updated", "skill": name, "file": filename}) + return {"status": "ok"} + +@app.delete("/api/skills/{name}/context/{filename}") +def delete_skill_context_file(name: str, filename: str): + path = skill_context_file_path(name, filename) + if not path.exists(): + raise HTTPException(404, "File not found") + path.unlink() + append_audit({"action": "skill_context_deleted", "skill": name, "file": filename}) + return {"status": "deleted"} + @app.post("/api/skills/{name}/run") def run_skill(name: str, req: Optional[SkillRunRequest] = None): - path = BASE_DIR / "skills" / name + path = skill_dir_path(name) if not path.exists(): raise HTTPException(404, "Skill not found") @@ -335,7 +412,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None): @app.get("/api/skills/{name}/eval") def get_skill_eval(name: str): - path = BASE_DIR / "skills" / name / "score-history.json" + path = skill_dir_path(name) / "score-history.json" if not path.exists(): return {"scores": []} return {"scores": json.loads(path.read_text())}