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) {
-
-
${escapeHtml(skill.skill || 'No SKILL.md')}
+
+
${escapeHtml(skill.skill || 'No SKILL.md')}
@@ -120,10 +126,20 @@ async function showSkillDetail(name) {
` : '
No evaluation scores yet
'}
-
- ${skill.context && skill.context.length > 0
- ? `
${skill.context.map(f => `${f}`).join('')}
`
- : '
No 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', `
+
+
+
+
+
+ `, `
+
+
+ `);
+}
+
+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())}