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:
Claude 2026-07-07 17:22:41 +00:00
parent 085e500908
commit d36785c7ee
3 changed files with 229 additions and 9 deletions

View File

@ -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)}`),

View File

@ -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&#10;&#10;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');
}
}

View File

@ -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())}