Add sync-to-DB and delete-from-DB actions for skills

- POST /api/admin/skills/{slug}/sync — read SKILL.md from disk, insert/update DB
- DELETE /api/admin/skills/{slug} — remove skill from DB (keeps disk files)
- UI: Sync/Re-sync and Delete buttons per skill in the skills list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 17:52:00 +00:00
parent 2d265d2f0e
commit d8e888ad6a
2 changed files with 130 additions and 0 deletions

View File

@@ -994,6 +994,94 @@ async def api_install_skill(file: UploadFile = File(...)):
}
@app.post("/api/admin/skills/{slug}/sync")
async def api_sync_skill(slug: str):
"""Sync a skill from disk into the DB (for skills that exist on disk but not in DB)."""
skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug
if not skill_dir.exists():
raise HTTPException(404, f"Skill directory not found on disk: {slug}")
skill_md_file = skill_dir / "SKILL.md"
if not skill_md_file.exists():
raise HTTPException(400, f"No SKILL.md found in {slug}")
markdown_content = skill_md_file.read_text(encoding="utf-8")
# Build file_inventory from disk
file_inventory = []
for f in sorted(skill_dir.rglob("*")):
if not f.is_file():
continue
rel = str(f.relative_to(skill_dir))
if rel.startswith(".") or "/__MACOSX/" in rel:
continue
if rel == "SKILL.md":
kind = "skill"
elif rel.startswith("scripts/"):
kind = "script"
elif rel.startswith("references/"):
kind = "reference"
elif rel.endswith(".zip"):
kind = "archive"
else:
kind = "resource"
file_inventory.append({"kind": kind, "path": rel})
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
existing = await conn.fetchval(
"SELECT id FROM company_skills WHERE company_id = $1::uuid AND slug = $2",
SKILLS_COMPANY_ID, slug,
)
if existing:
await conn.execute(
"""UPDATE company_skills
SET markdown = $1, file_inventory = $2::jsonb, updated_at = now()
WHERE id = $3""",
markdown_content,
json.dumps(file_inventory, ensure_ascii=False),
existing,
)
action = "updated"
else:
await conn.execute(
"""INSERT INTO company_skills
(company_id, key, slug, name, markdown, source_type, file_inventory)
VALUES ($1::uuid, $2, $3, $4, $5, 'local_path', $6::jsonb)""",
SKILLS_COMPANY_ID, slug, slug, slug,
markdown_content,
json.dumps(file_inventory, ensure_ascii=False),
)
action = "inserted"
finally:
await conn.close()
return {
"slug": slug,
"action": action,
"file_inventory": file_inventory,
"markdown_chars": len(markdown_content),
}
@app.delete("/api/admin/skills/{slug}")
async def api_delete_skill(slug: str):
"""Delete a skill from the DB. Does NOT delete files from disk."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
result = await conn.execute(
"DELETE FROM company_skills WHERE company_id = $1::uuid AND slug = $2",
SKILLS_COMPANY_ID, slug,
)
finally:
await conn.close()
if result == "DELETE 0":
raise HTTPException(404, f"Skill '{slug}' not found in DB")
return {"slug": slug, "action": "deleted"}
@app.post("/api/admin/paperclip/restart")
async def api_restart_paperclip():
"""Restart the Paperclip PM2 process.

View File

@@ -1293,6 +1293,16 @@ async function loadSkillList() {
const updatedStr = s.updated_at ? new Date(s.updated_at).toLocaleDateString('he-IL') : '—';
const inDb = s.db_markdown_chars > 0;
const onDisk = s.disk_exists;
// Action buttons
const actions = [];
if (onDisk && !inDb) {
actions.push(`<button class="btn btn-sm btn-primary" onclick="syncSkill('${esc(s.slug)}')">Sync to DB</button>`);
} else if (onDisk && inDb) {
actions.push(`<button class="btn btn-sm btn-secondary" onclick="syncSkill('${esc(s.slug)}')">Re-sync</button>`);
}
if (inDb) {
actions.push(`<button class="btn btn-sm btn-outline" style="color:#e94560;border-color:#e94560" onclick="deleteSkill('${esc(s.slug)}')">Delete from DB</button>`);
}
return `
<div class="skill-item">
<span class="skill-icon">&#128268;</span>
@@ -1309,6 +1319,7 @@ async function loadSkillList() {
${inDb ? '<span class="badge-ok">DB</span>' : '<span class="badge-warn">No DB</span>'}
${onDisk ? '<span class="badge-ok">Disk</span>' : '<span class="badge-warn">No Disk</span>'}
</div>
<div style="display:flex;gap:6px;flex-shrink:0">${actions.join('')}</div>
</div>`;
}).join('');
} catch (e) {
@@ -1376,6 +1387,37 @@ async function installSkill(file) {
}
}
async function syncSkill(slug) {
try {
const res = await fetch(API + '/admin/skills/' + encodeURIComponent(slug) + '/sync', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
toast(data.detail || 'Sync failed', 'error');
return;
}
toast(`${data.action}: ${slug} (${data.markdown_chars.toLocaleString()} chars, ${data.file_inventory.length} files)`, 'success');
loadSkillList();
} catch (e) {
toast('Network error', 'error');
}
}
async function deleteSkill(slug) {
if (!confirm(`Delete "${slug}" from DB? Files on disk will NOT be removed.`)) return;
try {
const res = await fetch(API + '/admin/skills/' + encodeURIComponent(slug), { method: 'DELETE' });
const data = await res.json();
if (!res.ok) {
toast(data.detail || 'Delete failed', 'error');
return;
}
toast(`Deleted: ${slug}`, 'success');
loadSkillList();
} catch (e) {
toast('Network error', 'error');
}
}
function confirmRestart() {
if (!confirm('Restart Paperclip?')) return;
restartPaperclip();