From d8e888ad6aad9eacd85371206bbca80587ee9460 Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 8 Apr 2026 17:52:00 +0000 Subject: [PATCH] Add sync-to-DB and delete-from-DB actions for skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web/app.py | 88 +++++++++++++++++++++++++++++++++++++++++++ web/static/index.html | 42 +++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/web/app.py b/web/app.py index 0e3c098..86acdbe 100644 --- a/web/app.py +++ b/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. diff --git a/web/static/index.html b/web/static/index.html index a3e5008..a945b81 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -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(``); + } else if (onDisk && inDb) { + actions.push(``); + } + if (inDb) { + actions.push(``); + } return `
🔌 @@ -1309,6 +1319,7 @@ async function loadSkillList() { ${inDb ? 'DB' : 'No DB'} ${onDisk ? 'Disk' : 'No Disk'}
+
${actions.join('')}
`; }).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();