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:
88
web/app.py
88
web/app.py
@@ -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.
|
||||
|
||||
@@ -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">🔌</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();
|
||||
|
||||
Reference in New Issue
Block a user