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")
|
@app.post("/api/admin/paperclip/restart")
|
||||||
async def api_restart_paperclip():
|
async def api_restart_paperclip():
|
||||||
"""Restart the Paperclip PM2 process.
|
"""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 updatedStr = s.updated_at ? new Date(s.updated_at).toLocaleDateString('he-IL') : '—';
|
||||||
const inDb = s.db_markdown_chars > 0;
|
const inDb = s.db_markdown_chars > 0;
|
||||||
const onDisk = s.disk_exists;
|
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 `
|
return `
|
||||||
<div class="skill-item">
|
<div class="skill-item">
|
||||||
<span class="skill-icon">🔌</span>
|
<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>'}
|
${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>'}
|
${onDisk ? '<span class="badge-ok">Disk</span>' : '<span class="badge-warn">No Disk</span>'}
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-shrink:0">${actions.join('')}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) {
|
} 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() {
|
function confirmRestart() {
|
||||||
if (!confirm('Restart Paperclip?')) return;
|
if (!confirm('Restart Paperclip?')) return;
|
||||||
restartPaperclip();
|
restartPaperclip();
|
||||||
|
|||||||
Reference in New Issue
Block a user