Add Paperclip skill install/upgrade UI and API

- POST /api/admin/skills/install — upload ZIP, extract to skills dir, update DB
- GET /api/admin/skills — list installed skills with DB/disk sync status
- POST /api/admin/paperclip/restart — restart Paperclip (pm2 or flag file)
- New Skills page in web UI with drag-and-drop ZIP upload
- Coolify volume mount for /paperclip-skills
- Host-side crontab watcher for restart flag file

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

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
@@ -17,11 +18,15 @@ from uuid import UUID, uuid4
# Allow importing legal_mcp from the MCP server source
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "mcp-server" / "src"))
import zipfile
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import asyncpg
from legal_mcp import config
from legal_mcp.services import chunker, db, embeddings, extractor, processor
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools
@@ -774,6 +779,249 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest):
return project
# ── Skill Management API ───────────────────────────────────────────
PAPERCLIP_DB_URL = os.environ.get(
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
)
# In Docker: mounted at /paperclip-skills; locally: ~/.paperclip/instances/default/skills
_docker_skills = Path("/paperclip-skills")
_local_skills = Path.home() / ".paperclip" / "instances" / "default" / "skills"
PAPERCLIP_SKILLS_DIR = _docker_skills if _docker_skills.exists() else _local_skills
# Default company ID for skills
SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294")
@app.get("/api/admin/skills")
async def api_list_skills():
"""List installed Paperclip skills with DB sync status."""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
SKILLS_COMPANY_ID,
)
finally:
await conn.close()
skills = []
for r in rows:
slug = r["slug"]
skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug
disk_exists = skill_dir.exists()
disk_skill_md = None
if disk_exists:
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
disk_skill_md = skill_md.stat().st_size
skills.append({
"slug": slug,
"name": r["name"],
"db_markdown_chars": r["md_chars"],
"file_inventory": json.loads(r["file_inventory"]) if isinstance(r["file_inventory"], str) else r["file_inventory"],
"updated_at": r["updated_at"].isoformat() if r["updated_at"] else None,
"disk_exists": disk_exists,
"disk_skill_md_bytes": disk_skill_md,
})
# Also check for skills on disk that aren't in DB
company_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID
if company_dir.exists():
db_slugs = {s["slug"] for s in skills}
for d in sorted(company_dir.iterdir()):
if d.is_dir() and d.name not in db_slugs:
skill_md = d / "SKILL.md"
skills.append({
"slug": d.name,
"name": d.name,
"db_markdown_chars": 0,
"file_inventory": [],
"updated_at": None,
"disk_exists": True,
"disk_skill_md_bytes": skill_md.stat().st_size if skill_md.exists() else None,
"not_in_db": True,
})
return skills
@app.post("/api/admin/skills/install")
async def api_install_skill(file: UploadFile = File(...)):
"""Install or update a Paperclip skill from a ZIP file.
The ZIP should contain a SKILL.md at root (or in a single subdirectory).
The skill slug is derived from the directory name or ZIP filename.
"""
if not file.filename:
raise HTTPException(400, "No filename provided")
if not file.filename.lower().endswith(".zip"):
raise HTTPException(400, "Only ZIP files are supported")
content = await file.read()
if len(content) > 100 * 1024 * 1024: # 100MB limit
raise HTTPException(400, "File too large (max 100MB)")
import io
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile:
raise HTTPException(400, "Invalid ZIP file")
# Find SKILL.md and determine the skill root
skill_md_path = None
skill_root = ""
names = zf.namelist()
for name in names:
basename = name.split("/")[-1]
if basename == "SKILL.md":
skill_md_path = name
# Root is everything before SKILL.md
skill_root = name[: -len("SKILL.md")]
break
if not skill_md_path:
zf.close()
raise HTTPException(400, "ZIP must contain a SKILL.md file")
# Determine slug: from directory name in ZIP, or from ZIP filename
if skill_root and skill_root.strip("/"):
slug = skill_root.strip("/").split("/")[0]
else:
slug = Path(file.filename).stem.lower()
slug = re.sub(r"[^\w\-]", "-", slug).strip("-")
# Extract to skill directory
skill_dir = PAPERCLIP_SKILLS_DIR / SKILLS_COMPANY_ID / slug
skill_dir.mkdir(parents=True, exist_ok=True)
# Clear existing contents
for item in skill_dir.rglob("*"):
if item.is_file():
item.unlink()
# Remove empty subdirs
for item in sorted(skill_dir.rglob("*"), reverse=True):
if item.is_dir():
try:
item.rmdir()
except OSError:
pass
# Extract files, stripping the skill_root prefix
extracted_files = []
for name in names:
if name.endswith("/"):
continue # skip directories
if not name.startswith(skill_root):
continue # skip files outside skill root
rel_path = name[len(skill_root):]
if not rel_path:
continue
# Skip macOS metadata
if "/__MACOSX/" in name or rel_path.startswith("__MACOSX/") or rel_path.startswith("."):
continue
dest = skill_dir / rel_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(name))
extracted_files.append(rel_path)
zf.close()
# Read SKILL.md content
skill_md_file = skill_dir / "SKILL.md"
if not skill_md_file.exists():
raise HTTPException(500, "SKILL.md was not extracted properly")
markdown_content = skill_md_file.read_text(encoding="utf-8")
# Build file_inventory
file_inventory = []
for rel in sorted(extracted_files):
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})
# Update DB
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 = "installed"
finally:
await conn.close()
return {
"slug": slug,
"action": action,
"files_extracted": len(extracted_files),
"file_inventory": file_inventory,
"markdown_chars": len(markdown_content),
}
@app.post("/api/admin/paperclip/restart")
async def api_restart_paperclip():
"""Restart the Paperclip PM2 process.
Tries pm2 directly (works when running locally on the host).
In Docker, writes a restart flag file that the host watcher picks up.
"""
# Try pm2 directly (works when running outside Docker)
result = subprocess.run(
["pm2", "restart", "paperclip"],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0:
return {"status": "restarted", "method": "pm2", "output": result.stdout.strip()}
# Fallback: write a flag file that host-side watcher picks up
flag_file = PAPERCLIP_SKILLS_DIR / ".restart-requested"
try:
flag_file.write_text(str(time.time()))
return {
"status": "restart_requested",
"method": "flag_file",
"message": "Restart requested — the host watcher will restart Paperclip shortly.",
}
except Exception:
raise HTTPException(500, "Cannot restart Paperclip from Docker. Run manually: pm2 restart paperclip")
@app.post("/api/cases/{case_number}/documents/upload-tagged")
async def api_upload_tagged_document(
case_number: str,

View File

@@ -241,6 +241,27 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
.empty { text-align: center; color: #bbb; padding: 40px 20px; font-size: 0.88em; line-height: 1.6; }
/* ── Skills Management ───────────────────────────────── */
.skill-list { display: flex; flex-direction: column; gap: 10px; }
.skill-item {
display: flex; align-items: center; gap: 14px; padding: 14px 18px;
border: 1px solid #eee; border-radius: 8px; background: #fafafa;
transition: background 0.15s;
}
.skill-item:hover { background: #f0f0f0; }
.skill-item .skill-icon { font-size: 1.5em; flex-shrink: 0; }
.skill-item .skill-info { flex: 1; min-width: 0; }
.skill-item .skill-name { font-size: 0.95em; font-weight: 600; }
.skill-item .skill-meta { font-size: 0.75em; color: #999; margin-top: 3px; display: flex; gap: 14px; flex-wrap: wrap; }
.skill-item .skill-badges { display: flex; gap: 6px; flex-shrink: 0; }
.skill-item .badge-ok { background: #e8f5e9; color: #388e3c; padding: 2px 8px; border-radius: 4px; font-size: 0.72em; font-weight: 600; }
.skill-item .badge-warn { background: #fff3e0; color: #f57c00; padding: 2px 8px; border-radius: 4px; font-size: 0.72em; font-weight: 600; }
.skill-install-result {
margin-top: 16px; padding: 16px; border-radius: 8px;
background: #e8f5e9; border: 1px solid #c8e6c9; font-size: 0.88em;
}
.skill-install-result.error { background: #ffebee; border-color: #ffcdd2; }
@media (max-width: 800px) {
.main { padding: 16px; }
header { padding: 14px 16px; }
@@ -260,6 +281,7 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
<a href="#/" id="navHome">תיקים</a>
<a href="#/new" id="navNew">+ תיק חדש</a>
<a href="#/upload" id="navUpload">העלאה</a>
<a href="#/skills" id="navSkills">Skills</a>
</nav>
</header>
@@ -442,6 +464,46 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
</div>
</div>
<!-- ══ Page: Skills Management ══ -->
<div class="page" id="page-skills">
<div class="page-header">
<h2>Paperclip Skills</h2>
</div>
<!-- Install Skill -->
<div class="card">
<div class="card-header">התקנה / שדרוג Skill</div>
<div class="card-body">
<div class="upload-zone" id="skillDropZone">
<div style="font-size:3em;color:#ccc;margin-bottom:16px">&#128268;</div>
<h3>גרור קובץ ZIP של Skill או לחץ לבחירה</h3>
<p>ZIP עם SKILL.md, scripts/, references/ — לפי מבנה Anthropic</p>
<input type="file" id="skillFileInput" accept=".zip">
</div>
<div id="skillInstallResult"></div>
</div>
</div>
<!-- Installed Skills -->
<div class="card">
<div class="card-header">Skills מותקנים</div>
<div class="card-body">
<div class="skill-list" id="skillList">
<div class="empty">טוען...</div>
</div>
</div>
</div>
<!-- Restart Paperclip -->
<div class="card">
<div class="card-header">Paperclip Server</div>
<div class="card-body" style="display:flex;align-items:center;gap:16px">
<button class="btn btn-secondary" id="restartPaperclipBtn" onclick="restartPaperclip()">Restart Paperclip</button>
<span id="restartStatus" style="font-size:0.85em;color:#888"></span>
</div>
</div>
</div>
<!-- ══ Page: Legacy Upload ══ -->
<div class="page" id="page-upload">
<div class="card"><div class="card-body">
@@ -506,6 +568,12 @@ function handleRoute() {
subtitle = 'ערר ' + caseNum;
currentCaseNumber = caseNum;
loadCaseView(caseNum);
} else if (hash === '#/skills') {
document.getElementById('page-skills').classList.add('active');
document.getElementById('navSkills').classList.add('active');
subtitle = 'Paperclip Skills';
loadSkillList();
setupSkillUpload();
} else if (hash === '#/upload') {
document.getElementById('page-upload').classList.add('active');
document.getElementById('navUpload').classList.add('active');
@@ -1210,6 +1278,138 @@ function toast(msg, type = '') {
setTimeout(() => el.className = 'toast', 3000);
}
// ── Skills Management ───────────────────────────────────
async function loadSkillList() {
const list = document.getElementById('skillList');
try {
const res = await fetch(API + '/admin/skills');
const skills = await res.json();
if (!skills.length) {
list.innerHTML = '<div class="empty">אין skills מותקנים</div>';
return;
}
list.innerHTML = skills.map(s => {
const fileCount = s.file_inventory ? s.file_inventory.length : 0;
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;
return `
<div class="skill-item">
<span class="skill-icon">&#128268;</span>
<div class="skill-info">
<div class="skill-name">${esc(s.name)}</div>
<div class="skill-meta">
<span>${fileCount} files</span>
<span>${s.db_markdown_chars.toLocaleString()} chars</span>
<span>Updated: ${updatedStr}</span>
${s.disk_skill_md_bytes ? `<span>Disk: ${formatSize(s.disk_skill_md_bytes)}</span>` : ''}
</div>
</div>
<div class="skill-badges">
${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>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="empty">שגיאה בטעינת skills</div>';
}
}
function setupSkillUpload() {
const dropZone = document.getElementById('skillDropZone');
const fileInput = document.getElementById('skillFileInput');
const newDrop = dropZone.cloneNode(true);
dropZone.parentNode.replaceChild(newDrop, dropZone);
const newInput = newDrop.querySelector('input[type="file"]');
newDrop.addEventListener('click', () => newInput.click());
newDrop.addEventListener('dragover', e => { e.preventDefault(); newDrop.classList.add('dragover'); });
newDrop.addEventListener('dragleave', () => newDrop.classList.remove('dragover'));
newDrop.addEventListener('drop', e => {
e.preventDefault();
newDrop.classList.remove('dragover');
if (e.dataTransfer.files.length) installSkill(e.dataTransfer.files[0]);
});
newInput.addEventListener('change', () => {
if (newInput.files.length) installSkill(newInput.files[0]);
newInput.value = '';
});
}
async function installSkill(file) {
if (!file.name.toLowerCase().endsWith('.zip')) {
return toast('Only ZIP files are supported', 'error');
}
const resultDiv = document.getElementById('skillInstallResult');
resultDiv.innerHTML = '<div class="skill-install-result">Installing...</div>';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(API + '/admin/skills/install', {
method: 'POST', body: formData,
});
const data = await res.json();
if (!res.ok) {
resultDiv.innerHTML = `<div class="skill-install-result error">${esc(data.detail || 'Installation failed')}</div>`;
toast('Installation failed', 'error');
return;
}
const actionLabel = data.action === 'updated' ? 'Updated' : 'Installed';
resultDiv.innerHTML = `
<div class="skill-install-result">
<b>${actionLabel}: ${esc(data.slug)}</b><br>
${data.files_extracted} files extracted &middot; ${data.markdown_chars.toLocaleString()} chars<br><br>
<button class="btn btn-primary" onclick="confirmRestart()">Restart Paperclip</button>
<span style="font-size:0.82em;color:#888;margin-right:12px">Restart required to apply changes</span>
</div>`;
toast(actionLabel + ': ' + data.slug, 'success');
loadSkillList();
} catch (e) {
resultDiv.innerHTML = `<div class="skill-install-result error">Network error</div>`;
toast('Network error', 'error');
}
}
function confirmRestart() {
if (!confirm('Restart Paperclip?')) return;
restartPaperclip();
}
async function restartPaperclip() {
const btn = document.getElementById('restartPaperclipBtn');
const status = document.getElementById('restartStatus');
btn.disabled = true;
btn.textContent = 'Restarting...';
status.textContent = '';
try {
const res = await fetch(API + '/admin/paperclip/restart', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
status.textContent = 'Error: ' + (data.detail || 'failed');
status.style.color = '#e94560';
toast('Restart failed', 'error');
} else {
status.textContent = 'Restarted successfully';
status.style.color = '#27ae60';
toast('Paperclip restarted', 'success');
}
} catch (e) {
status.textContent = 'Network error';
status.style.color = '#e94560';
toast('Network error', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Restart Paperclip';
}
}
// Init legacy upload listeners
setupLegacyUpload();
</script>