Files
ai-agent/backend/templates.py
Gabriel Vidal 6359f7a3c2 feat(templates): tag skill templates in the template browser
A skill template has no package.json and no pyproject -- its CLI is a
standalone PEP-723 uv script -- so _stack() read nothing off skill-cli and it
listed with no tags at all. Sniff the .py sources for the markers instead, and
tag a root SKILL.md as Skill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 01:52:06 +02:00

332 lines
11 KiB
Python

"""Browse the homelab project scaffolding templates (projects/templates/).
Merged in from the former standalone `project-templates` service: lists each
template under TEMPLATES_DIR, walks its file tree and serves individual text
file contents so a scaffold can be previewed before use. Pure helpers — the
FastAPI routes live in main.py.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from fastapi import HTTPException
TEMPLATES_DIR = Path(
os.environ.get("TEMPLATES_DIR", "/workspace/projects/templates")).resolve()
# Directories we never descend into or list, and the cap for file previews.
IGNORE_DIRS = {
"node_modules", ".git", "dist", ".turbo", "__pycache__",
".venv", "venv", ".next", "build", ".cache", "pb_data",
}
# Files that are pure noise in a template browser.
IGNORE_FILES = {".DS_Store"}
MAX_FILE_BYTES = 512 * 1024 # 512 KB cap for a single preview
# Extensions we treat as text and are willing to serve inline.
TEXT_EXT = {
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".md", ".mdx",
".css", ".scss", ".html", ".htm", ".svg", ".txt", ".yml", ".yaml",
".toml", ".sh", ".bash", ".py", ".env", ".example", ".gitignore",
".dockerignore", ".lock", ".cfg", ".ini", ".conf", ".xml", ".rs",
".go", ".sql", ".gitkeep",
}
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _template_dir(name: str) -> Path:
"""Resolve a template name to a directory inside TEMPLATES_DIR, safely."""
if not name or "/" in name or name.startswith("."):
raise HTTPException(400, "bad template name")
target = (TEMPLATES_DIR / name).resolve()
if target.parent != TEMPLATES_DIR or not target.is_dir():
raise HTTPException(404, "template not found")
return target
def _resolve_in(base: Path, rel: str) -> Path:
"""Safely resolve a relative path inside `base`, blocking traversal."""
target = (base / rel).resolve()
if base != target and base not in target.parents:
raise HTTPException(400, "path escapes template")
return target
def _is_text(path: Path) -> bool:
name = path.name
if name in {"Dockerfile", "Makefile", "LICENSE", "README"}:
return True
return path.suffix.lower() in TEXT_EXT
def _walk_stats(root: Path) -> tuple[int, int]:
"""Return (file_count, total_bytes) ignoring IGNORE_DIRS."""
files = 0
size = 0
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS]
for fn in filenames:
if fn in IGNORE_FILES:
continue
files += 1
try:
size += (Path(dirpath) / fn).stat().st_size
except OSError:
pass
return files, size
def _read_readme(root: Path) -> str | None:
for cand in ("README.md", "README", "readme.md"):
p = root / cand
if p.is_file():
try:
return p.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
return None
def _read_package_json(root: Path) -> dict | None:
p = root / "package.json"
if not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8", errors="replace"))
except (OSError, json.JSONDecodeError):
return None
def _clean(text: str) -> str:
"""Drop unfilled scaffold placeholders like @@DESCRIPTION@@."""
return "" if "@@" in text else text
def _description(root: Path, pkg: dict | None, readme: str | None) -> str:
if pkg and isinstance(pkg.get("description"), str):
desc = _clean(pkg["description"].strip())
if desc:
return desc
if readme:
for line in readme.splitlines():
s = line.strip()
if s and not s.startswith("#") and not s.startswith("!["):
cleaned = _clean(s)
if cleaned:
return cleaned
return ""
# Subdirs a fullstack template might keep its frontend / backend under, so the
# stack tags are right even when package.json / requirements.txt aren't at root.
_FRONTEND_DIRS = ("", "frontend", "web", "ui", "client", "app")
_BACKEND_DIRS = ("", "backend", "server", "api", "app")
# How much .py source _stack() sniffs for framework markers. Templates are small
# scaffolds, so a handful of heads is plenty and keeps the listing cheap.
_PY_SNIFF_FILES = 24
_PY_SNIFF_BYTES = 4096
def _python_sources(root: Path) -> str:
"""Concatenate the heads of the template's .py files (ignoring IGNORE_DIRS)."""
parts: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in IGNORE_DIRS]
for fn in filenames:
if not fn.endswith(".py"):
continue
try:
with open(Path(dirpath) / fn, encoding="utf-8", errors="replace") as fh:
parts.append(fh.read(_PY_SNIFF_BYTES))
except OSError:
continue
if len(parts) >= _PY_SNIFF_FILES:
return "".join(parts)
return "".join(parts)
def _stack(root: Path, pkg: dict | None) -> list[str]:
"""Best-effort detection of the tech in a template, as short tags."""
tags: list[str] = []
# Gather npm deps from a root package.json and any nested frontend one.
deps: dict = {}
if pkg:
deps.update(pkg.get("dependencies", {}))
deps.update(pkg.get("devDependencies", {}))
for sub in _FRONTEND_DIRS:
if sub:
nested = _read_package_json(root / sub)
if nested:
deps.update(nested.get("dependencies", {}))
deps.update(nested.get("devDependencies", {}))
has = lambda *names: any(n in deps for n in names) # noqa: E731
def _exists(subs: tuple[str, ...], *names: str) -> bool:
return any((root / sub / n).exists() for sub in subs for n in names)
if has("react", "react-dom"):
tags.append("React")
if has("vue"):
tags.append("Vue")
if has("svelte"):
tags.append("Svelte")
if has("vite", "@vitejs/plugin-react"):
tags.append("Vite")
if has("typescript") or _exists(_FRONTEND_DIRS, "tsconfig.json"):
tags.append("TypeScript")
if has("tailwindcss", "@tailwindcss/vite"):
tags.append("Tailwind")
if has("rxdb"):
tags.append("RxDB")
# Python / FastAPI from requirements.txt or pyproject in root or a backend dir.
py_files = [
(root / sub / n)
for sub in _BACKEND_DIRS
for n in ("requirements.txt", "pyproject.toml")
if (root / sub / n).is_file()
]
if py_files:
tags.append("Python")
blob = ""
for p in py_files:
try:
blob += p.read_text(encoding="utf-8", errors="replace").lower()
except OSError:
pass
if "fastapi" in blob:
tags.append("FastAPI")
# Skill templates (a root SKILL.md, scaffolded by `new-skill`) carry no
# package.json and no pyproject: their CLI is a standalone PEP-723 uv script,
# so the tech has to be read off the .py sources themselves or they'd show up
# with no tags at all.
if (root / "SKILL.md").is_file():
tags.append("Skill")
src = _python_sources(root)
if src:
if "Python" not in tags:
tags.append("Python")
if "import click" in src:
tags.append("Click")
if "# /// script" in src:
tags.append("uv script")
if (root / "Dockerfile").exists():
tags.append("Docker")
if (root / "docker-compose.yml").exists() or (root / "compose.yml").exists():
tags.append("Compose")
if (root / "pocketbase").exists():
tags.append("PocketBase")
return tags
def _tree(root: Path, base: Path) -> list[dict]:
"""Build a sorted nested file tree (dirs first), ignoring IGNORE_DIRS."""
out: list[dict] = []
try:
entries = sorted(
root.iterdir(),
key=lambda p: (p.is_file(), p.name.lower()),
)
except OSError:
return out
for entry in entries:
if entry.is_dir():
if entry.name in IGNORE_DIRS:
continue
out.append({
"name": entry.name,
"path": str(entry.relative_to(base)),
"type": "dir",
"children": _tree(entry, base),
})
else:
if entry.name in IGNORE_FILES:
continue
try:
size = entry.stat().st_size
except OSError:
size = 0
out.append({
"name": entry.name,
"path": str(entry.relative_to(base)),
"type": "file",
"size": size,
"text": _is_text(entry),
})
return out
# --------------------------------------------------------------------------- #
# public API (consumed by main.py routes)
# --------------------------------------------------------------------------- #
def list_templates() -> list[dict]:
# Absent scaffolds are a valid state, not an error: with no templates mount
# (a standalone `docker run`) the tab shows an empty gallery, like every
# other catalog here. Only a *named* template that isn't there is a 404.
if not TEMPLATES_DIR.is_dir():
return []
items: list[dict] = []
for entry in sorted(TEMPLATES_DIR.iterdir(), key=lambda p: p.name.lower()):
if not entry.is_dir() or entry.name in IGNORE_DIRS or entry.name.startswith("."):
continue
pkg = _read_package_json(entry)
readme = _read_readme(entry)
files, size = _walk_stats(entry)
items.append({
"name": entry.name,
"description": _description(entry, pkg, readme),
"stack": _stack(entry, pkg),
"fileCount": files,
"sizeBytes": size,
"hasReadme": readme is not None,
})
return items
def template_detail(name: str) -> dict:
root = _template_dir(name)
pkg = _read_package_json(root)
readme = _read_readme(root)
files, size = _walk_stats(root)
scripts = pkg.get("scripts", {}) if pkg else {}
deps = sorted((pkg.get("dependencies", {}) if pkg else {}).keys())
return {
"name": name,
"description": _description(root, pkg, readme),
"stack": _stack(root, pkg),
"fileCount": files,
"sizeBytes": size,
"readme": readme,
"scripts": scripts,
"dependencies": deps,
"tree": _tree(root, root),
}
def template_file(name: str, path: str) -> str:
root = _template_dir(name)
target = _resolve_in(root, path)
if not target.is_file():
raise HTTPException(404, "file not found")
if not _is_text(target):
raise HTTPException(415, "binary file — preview unavailable")
try:
size = target.stat().st_size
except OSError:
raise HTTPException(404, "file not found")
if size > MAX_FILE_BYTES:
raise HTTPException(413, f"file too large ({size} bytes)")
try:
return target.read_text(encoding="utf-8", errors="replace")
except OSError:
raise HTTPException(500, "could not read file")