Files
marvin 18fdff7d8c Import 13 skills from the old opencode setup into turnstone
skills/ carries the SKILL.md files; scripts/import_skills.py runs
inside the VM via turnstone's own storage layer (SKILL.md parser +
risk scanner) so rows match what the console admin API would write.
Server must be restarted after import (stale WAL snapshot).
2026-09-28 20:24:47 +02:00

121 lines
4.0 KiB
Python

#!/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()