diff --git a/api/main.py b/api/main.py index 90380fa..0d1b197 100644 --- a/api/main.py +++ b/api/main.py @@ -14,6 +14,7 @@ from api.routes.admin_kb import router as admin_kb_router from api.routes.health import router as health_router from api.routes.kb_public import router as kb_public_router from api.routes.smart_assistant import router as smart_assistant_router +from api.services.skills import seed_skills_from_image logging.basicConfig( level=logging.INFO, @@ -43,7 +44,7 @@ app.include_router(kb_public_router) @app.on_event("startup") async def ensure_data_dirs(): - """Create persistent data directories if they don't exist.""" + """Create persistent data directories and seed any new baked-in skills.""" data_dir = os.environ.get("SHIRA_DATA_DIR", "/opt/data") dirs = [ f"{data_dir}/skills", @@ -53,3 +54,6 @@ async def ensure_data_dirs(): for d in dirs: Path(d).mkdir(parents=True, exist_ok=True) logger.info("Data directories ready at %s", data_dir) + + added, skipped = seed_skills_from_image() + logger.info("Skills seed: %d new baked skill(s) added, %d already present", added, skipped) diff --git a/api/services/skills.py b/api/services/skills.py index bfdabca..bc6931a 100644 --- a/api/services/skills.py +++ b/api/services/skills.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import os import re +import shutil from pathlib import Path import yaml @@ -13,6 +14,11 @@ logger = logging.getLogger("shira.skills") SKILLS_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data") + "/skills" +# Baked-in copy of pre-seeded skills, kept inside the image at /app/config/skills/. +# Source-of-truth for skills shipped with the repo. Never under the persistent +# volume mount, so it survives across deploys and is read-only at runtime. +BAKED_SKILLS_DIR = Path(__file__).resolve().parents[2] / "config" / "skills" + _SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$") @@ -24,6 +30,31 @@ def _safe_skill_name(name: str) -> str: return name +def seed_skills_from_image() -> tuple[int, int]: + """Copy any baked-in skill that doesn't already exist in the persistent skills dir. + + Runs at startup. Newly pre-seeded skills (added to config/skills/ in the repo) + appear after the next deploy. Existing skills — whether learned by the model + or edited by a user — are never touched. Returns (added, skipped). + """ + target = Path(SKILLS_DIR) + target.mkdir(parents=True, exist_ok=True) + if not BAKED_SKILLS_DIR.is_dir(): + return 0, 0 + added = skipped = 0 + for src in sorted(BAKED_SKILLS_DIR.iterdir()): + if not (src.is_dir() and (src / "SKILL.md").is_file()): + continue + dst = target / src.name + if dst.exists(): + skipped += 1 + continue + shutil.copytree(src, dst) + added += 1 + logger.info("[skills] seeded baked skill: %s", src.name) + return added, skipped + + def _parse_frontmatter(text: str) -> tuple[dict, str]: """Parse YAML frontmatter from a SKILL.md file. Returns (meta, body).""" if not text.startswith("---"):