Make Skills Hub context files editable and add skill creation
Two real gaps: the Skills Hub had no way to create a new skill (with
a SKILL.md) at all - the only 'Install' flow was the Plugin registry,
which just records a name in a JSON file, not an actual skill folder.
And the Context Files panel was read-only, just listing filenames
with no way to view, edit, add, or delete their contents.
Adds POST /api/skills (create, with SKILL.md content), PUT
/api/skills/{name} (edit SKILL.md), and GET/PUT/DELETE
/api/skills/{name}/context/{filename} for context files - all
validated through the same regex-allowlist + resolved-path
containment pattern already used for kanban tasks. Dashboard gets a
'+ New Skill' button, an editable SKILL.md view, and per-file
edit/delete plus 'Add File' in the Context Files panel.
This commit is contained in:
parent
085e500908
commit
d36785c7ee
|
|
@ -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)}`),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ async function renderSkills() {
|
|||
</div>
|
||||
<div class="btn-group">
|
||||
<input id="skillFilter" class="form-input" style="width:200px" placeholder="Filter skills..." oninput="filterSkills()">
|
||||
<button class="btn btn-primary" onclick="showAddSkill()">+ New Skill</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs" id="skillTabs">
|
||||
|
|
@ -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 = `
|
||||
<div style="margin-bottom:16px">
|
||||
<button class="btn btn-ghost" onclick="backToSkills()">← Back to Skills</button>
|
||||
|
|
@ -100,8 +103,11 @@ async function showSkillDetail(name) {
|
|||
</div>
|
||||
<div class="grid grid-2">
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">📄 SKILL.md</span></div>
|
||||
<pre style="max-height:400px;overflow:auto;font-size:12px">${escapeHtml(skill.skill || 'No SKILL.md')}</pre>
|
||||
<div class="card-header">
|
||||
<span class="card-title">📄 SKILL.md</span>
|
||||
<button class="btn btn-sm btn-ghost" style="margin-left:auto" onclick="editSkillMd('${name}')">✎ Edit</button>
|
||||
</div>
|
||||
<pre id="skillMdView" style="max-height:400px;overflow:auto;font-size:12px">${escapeHtml(skill.skill || 'No SKILL.md')}</pre>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">📖 Learnings</span></div>
|
||||
|
|
@ -120,10 +126,20 @@ async function showSkillDetail(name) {
|
|||
` : '<div style="color:var(--text-muted);font-size:13px">No evaluation scores yet</div>'}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><span class="card-title">📁 Context Files</span></div>
|
||||
${skill.context && skill.context.length > 0
|
||||
? `<div style="display:flex;flex-wrap:wrap;gap:6px">${skill.context.map(f => `<span class="badge badge-info">${f}</span>`).join('')}</div>`
|
||||
: '<div style="color:var(--text-muted);font-size:13px">No context files</div>'}
|
||||
<div class="card-header">
|
||||
<span class="card-title">📁 Context Files</span>
|
||||
<button class="btn btn-sm btn-ghost" style="margin-left:auto" onclick="addSkillContextFile('${name}')">+ Add File</button>
|
||||
</div>
|
||||
<div id="skillContextList">
|
||||
${skill.context && skill.context.length > 0
|
||||
? skill.context.map(f => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:13px">
|
||||
<span style="flex:1">${escapeHtml(f)}</span>
|
||||
<button class="btn btn-sm btn-ghost" onclick="editSkillContextFile('${name}','${escapeHtml(f)}')">✎</button>
|
||||
<button class="btn btn-sm btn-ghost" style="color:var(--red)" onclick="deleteSkillContextFile('${name}','${escapeHtml(f)}')">🗑</button>
|
||||
</div>`).join('')
|
||||
: '<div style="color:var(--text-muted);font-size:13px">No context files</div>'}
|
||||
</div>
|
||||
${skill.eval && skill.eval.criteria ? `<div style="margin-top:12px"><strong style="font-size:12px">Eval Criteria:</strong><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">${skill.eval.criteria.map(c => `<span class="badge badge-accent">${c}</span>`).join('')}</div></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,3 +211,125 @@ async function executeSkillRun(name) {
|
|||
if (runBtn) { runBtn.textContent = '▶ Run'; runBtn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function showAddSkill() {
|
||||
showModal('New Skill', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
<input id="newSkillName" class="form-input" placeholder="e.g., my-custom-skill">
|
||||
<div class="form-hint">Letters, numbers, dashes and underscores only.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SKILL.md</label>
|
||||
<textarea id="newSkillMd" class="form-textarea" rows="12" placeholder="# My Custom Skill Describe what this skill does and how an agent should perform it..."></textarea>
|
||||
</div>
|
||||
`, `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="submitNewSkill()">Create Skill</button>
|
||||
`);
|
||||
}
|
||||
|
||||
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 = `
|
||||
<textarea id="skillMdEdit" class="form-textarea" rows="16" style="font-family:var(--font-mono);font-size:12px">${escapeHtml(current)}</textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn btn-sm btn-primary" onclick="saveSkillMd('${name}')">Save</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick="showSkillDetail('${name}')">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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', `
|
||||
<div class="form-group">
|
||||
<label class="form-label">File Name</label>
|
||||
<input id="newContextFilename" class="form-input" placeholder="e.g., reference.md">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Content</label>
|
||||
<textarea id="newContextContent" class="form-textarea" rows="10"></textarea>
|
||||
</div>
|
||||
`, `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="submitNewContextFile('${name}')">Add File</button>
|
||||
`);
|
||||
}
|
||||
|
||||
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}`, `
|
||||
<textarea id="editContextContent" class="form-textarea" rows="14">${escapeHtml(file.content)}</textarea>
|
||||
`, `
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveSkillContextFile('${name}','${filename}')">Save</button>
|
||||
`);
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
server.py
83
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())}
|
||||
|
|
|
|||
Loading…
Reference in New Issue