← Files WorldkeepARCHIVED FILE

skills/canon-viewer/scripts/view.py

7.05 KB · Oct 3, 2026 · 06:32 UTC

↓ Download file

#!/usr/bin/env python3
"""Worldbuilding canon viewer CLI (Stage 1: view discovery)."""

from __future__ import annotations

import argparse
import json
from pathlib import Path
import sys

SCRIPT_DIR = Path(__file__).resolve().parent
VENDOR_DIRS = (
    SCRIPT_DIR / "_vendor",
    SCRIPT_DIR.parent / "runtime" / "_vendor",
)
for vendor_dir in VENDOR_DIRS:
    if vendor_dir.is_dir():
        sys.path.insert(0, str(vendor_dir))
        break

from viewer.compile import CompileError, compile_view
from viewer.explain import explain_artifact, format_explanation
from viewer.load import CanonLoadError, ViewLoadError, builtin_everything, list_views, load_canon, load_view
from viewer.modules import ModuleError
from viewer.project import project_view, project_views
from viewer.render_graph import RenderError, render_graph, render_graph_document
from viewer.validate_view import validate_view


def _emit(args: argparse.Namespace, rendered: str) -> None:
    """Write text to the requested output file, or to stdout."""
    if args.output:
        output = args.output.expanduser().resolve()
        output.parent.mkdir(parents=True, exist_ok=True)
        output.write_text(rendered, encoding="utf-8", newline="\n")
    else:
        sys.stdout.write(rendered)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Read a KERNEL canon folder and generate a view."
    )
    parser.add_argument("canon_folder", type=Path, help="path to the canon folder")
    action = parser.add_mutually_exclusive_group(required=True)
    action.add_argument(
        "--list-views",
        action="store_true",
        help="list view files available in the canon folder",
    )
    action.add_argument(
        "--view",
        type=Path,
        action="append",
        help="view YAML path relative to the canon; repeat for one document",
    )
    action.add_argument(
        "--all-views",
        action="store_true",
        help="include every view in the canon, ordered by path",
    )
    action.add_argument(
        "--everything",
        action="store_true",
        help="render the viewer-owned Everything audit projection",
    )
    action.add_argument(
        "--validate-view",
        type=Path,
        help="check that a named view compiles and renders, without generating HTML",
    )
    action.add_argument(
        "--explain-view",
        type=Path,
        help="explain why one artifact appears as it does in a named view",
    )
    parser.add_argument(
        "--artifact",
        help="artifact id to explain; required with --explain-view",
    )
    parser.add_argument(
        "--write-lock",
        action="store_true",
        help="with --validate-view, record dependencies in an adjacent lock file",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="emit the Viewer v0 projection as JSON and skip rendering",
    )
    parser.add_argument("-o", "--output", type=Path, help="write output to this file")
    parser.add_argument(
        "--vendor",
        action="store_true",
        help="inline pinned local browser assets for fully offline HTML",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if args.list_views:
            if args.json or args.output or args.vendor:
                parser.error("--list-views cannot be combined with output options")
            views = list_views(args.canon_folder)
            for view in views:
                print(f"{view.relative_path}\t{view.name}")
                for warning in view.warnings:
                    print(f"warning: {warning}", file=sys.stderr)
            return 0

        if args.validate_view is not None:
            if args.vendor:
                parser.error("--vendor applies to HTML output, not --validate-view")
            canon = load_canon(args.canon_folder)
            view = load_view(args.canon_folder, args.validate_view)
            result = validate_view(canon, view, write_lock_file=args.write_lock)
            rendered = (
                json.dumps(result.as_json(), ensure_ascii=False, indent=2, default=str) + "\n"
                if args.json
                else result.as_text()
            )
            _emit(args, rendered)
            return 0 if result.ok else 1

        if args.explain_view is not None:
            if args.artifact is None:
                parser.error("--explain-view requires --artifact")
            if args.vendor:
                parser.error("--vendor applies to HTML output, not --explain-view")
            canon = load_canon(args.canon_folder)
            view = load_view(args.canon_folder, args.explain_view)
            plan = compile_view(canon, view)
            projection = project_view(canon, view, plan=plan)
            trace = explain_artifact(canon, plan, projection, args.artifact)
            rendered = (
                json.dumps(trace, ensure_ascii=False, indent=2, default=str) + "\n"
                if args.json
                else format_explanation(trace)
            )
            _emit(args, rendered)
            return 0

        if args.write_lock:
            parser.error("--write-lock applies to --validate-view")

        canon = load_canon(args.canon_folder)
        if args.all_views:
            views = list_views(args.canon_folder)
        elif args.everything:
            views = [builtin_everything()]
        else:
            views = [load_view(args.canon_folder, path) for path in args.view]
        projections = project_views(canon, views)
        for view, projection in zip(views, projections):
            for warning in projection["warnings"]:
                prefix = f"{view.relative_path}: " if len(views) > 1 else ""
                print(f"warning: {prefix}{warning}", file=sys.stderr)
        if args.json:
            if args.vendor:
                parser.error("--vendor applies to HTML output, not --json")
            payload = projections[0] if len(projections) == 1 else {"views": projections}
            rendered = json.dumps(
                payload, ensure_ascii=False, indent=2, default=str
            ) + "\n"
        else:
            if args.output is None:
                parser.error("HTML rendering requires -o/--output")
            render_args = {
                "vendor": args.vendor,
                "vendor_dir": Path(__file__).resolve().parent / "vendor",
            }
            if len(projections) == 1:
                rendered = render_graph(projections[0], canon, **render_args)
            else:
                rendered = render_graph_document(
                    projections,
                    canon,
                    explicit_layouts=["layout" in view.data for view in views],
                    view_paths=[view.relative_path for view in views],
                    **render_args,
                )
    except (CanonLoadError, ViewLoadError, ModuleError, CompileError, RenderError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    _emit(args, rendered)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 29b78312f28da96aa493fade8a30eca8f150895ffe1a4dd29e10a52634129ead