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:
248
web/app.py
248
web/app.py
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user