Files
FabledGrimoire/scripts/import_fabledrulebook.py
T
bvandeusenandClaude Opus 5.5 e99f1169f7
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
rename(547): what people read says Grimoire
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.

Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.

The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:46:37 -04:00

186 lines
6.5 KiB
Python

"""One-shot import of FabledRulebook .md files into the Grimoire DB.
Usage:
python -m scripts.import_fabledrulebook \\
--source-dir /path/to/FabledRulebook \\
--user-id 1 \\
[--rulebook-title "FabledSword family"] \\
[--subscribe-projects 3,4,5] \\
[--dry-run] \\
[--force]
Calls the services layer (services/rulebooks.py) — does not write raw SQL.
This means any structural mismatch surfaces as a clean service-level
ValueError. The script is not part of Alembic; the operator runs it once
after migration 0055 lands.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import re
from pathlib import Path
RULE_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
WHY_RE = re.compile(r"\*\*\s*Why:?\s*\*\*", re.IGNORECASE)
HOW_RE = re.compile(r"\*\*\s*How to apply:?\s*\*\*", re.IGNORECASE)
H1_RE = re.compile(r"^#\s+.+$", re.MULTILINE)
logger = logging.getLogger("import_fabledrulebook")
def parse_rule_body(title: str, body: str) -> dict:
"""Extract statement, why, how_to_apply from a rule's H2-section body."""
body = body.strip()
statement = body
why = ""
how_to_apply = ""
why_match = WHY_RE.search(body)
how_match = HOW_RE.search(body)
if why_match:
statement = body[: why_match.start()].strip()
if how_match and how_match.start() > why_match.end():
why = body[why_match.end() : how_match.start()].strip()
how_to_apply = body[how_match.end() :].strip()
else:
why = body[why_match.end() :].strip()
elif how_match:
statement = body[: how_match.start()].strip()
how_to_apply = body[how_match.end() :].strip()
return {
"title": title,
"statement": statement,
"why": why,
"how_to_apply": how_to_apply,
}
def parse_topic_file(path: Path) -> tuple[str, str, list[dict]]:
"""Returns (topic_title, topic_description, [rules])."""
text = path.read_text(encoding="utf-8")
topic_title = path.stem # e.g. "git-workflow"
parts = RULE_HEADING_RE.split(text)
preamble = parts[0] if parts else ""
# Strip H1 from preamble to get just the description.
description = H1_RE.sub("", preamble, count=1).strip()
rules: list[dict] = []
for i in range(1, len(parts), 2):
rule_title = parts[i].strip()
body = parts[i + 1] if i + 1 < len(parts) else ""
rules.append(parse_rule_body(rule_title, body))
return topic_title, description, rules
async def run(args) -> None:
source = Path(args.source_dir)
if not source.exists():
raise SystemExit(f"Source dir not found: {source}")
md_files = sorted(p for p in source.glob("*.md") if p.name.lower() != "readme.md")
if not md_files:
raise SystemExit(f"No topic .md files found in {source}")
readme = source / "README.md"
description = readme.read_text(encoding="utf-8") if readme.exists() else ""
if args.dry_run:
print(f"DRY RUN — would import {len(md_files)} topic file(s):")
for f in md_files:
topic_title, _, rules = parse_topic_file(f)
print(f" TOPIC: {topic_title} ({len(rules)} rules)")
for r in rules:
preview = r["statement"][:80] + ("…" if len(r["statement"]) > 80 else "")
empty_flag = " [EMPTY STATEMENT — will skip]" if not r["statement"].strip() else ""
print(f" - {r['title']}: {preview}{empty_flag}")
return
from scribe.services import rulebooks as rb_service
existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
if existing is not None and not args.force:
raise SystemExit(
f"Rulebook '{args.rulebook_title}' already exists for user "
f"{args.user_id}; pass --force to create another."
)
rb = await rb_service.create_rulebook(
user_id=args.user_id,
title=args.rulebook_title,
description=description,
)
logger.info("Created rulebook %d ('%s')", rb.id, rb.title)
rules_created = 0
rules_skipped = 0
for i, f in enumerate(md_files):
topic_title, topic_desc, rules = parse_topic_file(f)
topic = await rb_service.create_topic(
rulebook_id=rb.id,
user_id=args.user_id,
title=topic_title,
description=topic_desc,
order_index=i,
)
logger.info(" Created topic %d ('%s', %d rules)", topic.id, topic.title, len(rules))
for j, rule in enumerate(rules):
if not rule["statement"].strip():
logger.warning(" SKIP empty-statement rule: %s / %s", topic_title, rule["title"])
rules_skipped += 1
continue
await rb_service.create_rule(
topic_id=topic.id,
user_id=args.user_id,
title=rule["title"],
statement=rule["statement"],
why=rule["why"],
how_to_apply=rule["how_to_apply"],
order_index=j,
)
rules_created += 1
for pid in (args.subscribe_projects or []):
await rb_service.subscribe_project(
project_id=pid, rulebook_id=rb.id, user_id=args.user_id,
)
logger.info("Subscribed project %d to rulebook %d", pid, rb.id)
print(
f"\nImported rulebook '{rb.title}' (id={rb.id}): "
f"{len(md_files)} topics, {rules_created} rules created"
+ (f", {rules_skipped} skipped" if rules_skipped else "")
)
def _comma_ints(s: str) -> list[int]:
return [int(x) for x in s.split(",") if x.strip()]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", required=True,
help="Path to the FabledRulebook directory")
parser.add_argument("--user-id", type=int, required=True,
help="Grimoire user id who will own the rulebook")
parser.add_argument("--rulebook-title", default="FabledSword family")
parser.add_argument("--subscribe-projects", type=_comma_ints,
help="Comma-separated project ids to auto-subscribe")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true",
help="Allow creating a rulebook even if title exists")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(message)s")
asyncio.run(run(args))
if __name__ == "__main__":
main()