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,