#!/usr/bin/env python3 """Import SKILL.md skill directories into the turnstone DB. Runs INSIDE the VM (needs the turnstone venv), using turnstone's own storage layer and SKILL.md parser so rows match what the console admin API / in-session `skills` tool would have written (risk scan included): /opt/turnstone/bin/python import_skills.py --dir /tmp/skills The DB (data/turnstone.db on the host) is shared into the guest at /var/lib/turnstone/turnstone.db. Idempotent: existing skill names are skipped unless --update (which re-imports and appends a version row). """ from __future__ import annotations import argparse import json import pathlib import uuid from turnstone.core.skill_parser import parse_skill_md from turnstone.core.storage._sqlite import SQLiteBackend DB_DEFAULT = "/var/lib/turnstone/turnstone.db" def next_version(backend: SQLiteBackend, skill_id: str) -> int: try: rows = backend.list_skill_versions(skill_id) return max((int(r["version"]) for r in rows), default=0) + 1 except Exception: # noqa: BLE001 return 1 def import_one( backend: SQLiteBackend, skill_dir: pathlib.Path, update: bool ) -> str: raw = (skill_dir / "SKILL.md").read_text() p = parse_skill_md(raw) if p is None or not p.name or not p.description: return f"SKIP {skill_dir.name}: unparseable frontmatter" if not p.content.strip(): return f"SKIP {skill_dir.name}: empty body" allowed = p.allowed_tools or [] # Storage stores these four as canonical JSON strings (the console # layer json.dumps them before calling in). tags_str = json.dumps(p.tags or []) allowed_str = json.dumps(allowed) paths_str = json.dumps(p.paths or []) arguments_str = json.dumps(p.arguments or []) existing = backend.get_skill_by_name(p.name) if existing and not update: return f"SKIP {p.name}: already exists" if existing: skill_id = existing["template_id"] backend.update_prompt_template( skill_id, description=p.description, content=p.content, allowed_tools=allowed_str, skill_license=p.license or "", compatibility=(p.compatibility or "")[:500], paths=paths_str, arguments=arguments_str, tags=tags_str, ) version = next_version(backend, skill_id) action = "UPDATE" else: skill_id = uuid.uuid4().hex backend.create_prompt_template( template_id=skill_id, name=p.name, category="general", content=p.content, created_by="marvin", description=p.description, tags=tags_str, version=str(p.version or "1.0.0"), author=p.author or "", skill_license=p.license or "", compatibility=(p.compatibility or "")[:500], token_estimate=len(p.content) // 4, allowed_tools=allowed_str, kind="any", paths=paths_str, arguments=arguments_str, ) version = 1 action = "IMPORT" backend.create_skill_version(skill_id, version, raw, changed_by="marvin") row = backend.get_prompt_template(skill_id) return f"{action} {p.name} v{version} risk={row.get('risk_level') or 'none'}" def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--dir", required=True, help="directory of skill folders") ap.add_argument("--db", default=DB_DEFAULT) ap.add_argument("--update", action="store_true") args = ap.parse_args() backend = SQLiteBackend(args.db, create_tables=False) root = pathlib.Path(args.dir) dirs = sorted(d for d in root.iterdir() if (d / "SKILL.md").is_file()) if not dirs: raise SystemExit(f"no SKILL.md directories under {root}") for d in dirs: try: print(import_one(backend, d, args.update)) except Exception as e: # noqa: BLE001 print(f"FAIL {d.name}: {e}") if __name__ == "__main__": main()