Source code for simvx.core.cli

"""``simvx`` umbrella CLI.

Core ships a small dispatcher with these built-in subcommands:

    simvx run [FILE] [--root-class CLS] [--width W] [--height H]
    simvx new NAME [--template T]
    simvx lsp
    simvx context

Other packages extend the CLI via Python entry points — no upward imports
from core:

- ``simvx.commands``          — each entry registers a top-level subcommand
                                (e.g. ``simvx-editor`` registers ``editor``).
- ``simvx.export.targets``    — each entry registers a second-level target
                                under ``simvx export`` (e.g. ``simvx-web``
                                registers ``web``).
- ``simvx.project.templates`` — each entry contributes a project template
                                for ``simvx new --template``.

Entry-point callables must accept a single ``argparse._SubParsersAction`` and
attach their subparser(s), calling ``p.set_defaults(func=handler)`` so the
dispatcher knows how to run them.
"""

import argparse
import logging
import sys
from importlib.metadata import EntryPoint, entry_points
from pathlib import Path

log = logging.getLogger(__name__)

__all__ = ["main"]


# ---------------------------------------------------------------------------
# Built-in subcommand handlers
# ---------------------------------------------------------------------------


def _cmd_run(args: argparse.Namespace) -> int:
    from .run import run

    kwargs: dict = {}
    if args.width:
        kwargs["width"] = args.width
    if args.height:
        kwargs["height"] = args.height
    if args.file:
        kwargs["file"] = args.file
    if args.root_class:
        kwargs["class_name"] = args.root_class
    run(**kwargs)
    return 0


_DEFAULT_MAIN_PY = (
    "from simvx.core import Node2D, Property\n\n\n"
    "class Main(Node2D):\n"
    '    """Main scene — entry point for the game."""\n\n'
    "    def ready(self):\n"
    "        pass\n\n"
    "    def process(self, dt):\n"
    "        pass\n"
)

_DEFAULT_TOML = (
    '[display]\nwidth = 1280\nheight = 720\nvsync = true\nfullscreen = false\n'
    'stretch_mode = "viewport"\nstretch_aspect = "keep"\n\n'
    "[physics]\nfps = 60\ngravity = 9.8\n\n"
    "[audio]\nmaster_volume = 1.0\n\n"
    "[rendering]\n"
    'backend = "vulkan"\nmsaa = 0\n'
)


def _discover_templates() -> dict[str, EntryPoint]:
    """Return {template_name: entry_point} for every registered project template."""
    return {ep.name: ep for ep in entry_points(group="simvx.project.templates")}


def _cmd_new(args: argparse.Namespace) -> int:
    if args.template:
        templates = _discover_templates()
        if args.template not in templates:
            available = ", ".join(sorted(templates)) or "<none installed>"
            print(f"Unknown template {args.template!r}. Available: {available}", file=sys.stderr)
            return 1
        generator = templates[args.template].load()
        generator(args.name)
        print(f"Created project {args.name!r} from template {args.template!r}")
        return 0

    project_dir = Path(args.name)
    if project_dir.exists():
        print(f"Error: directory {project_dir} already exists", file=sys.stderr)
        return 1

    name = project_dir.name
    (project_dir / name).mkdir(parents=True)
    (project_dir / "assets").mkdir()
    (project_dir / "simvx.toml").write_text(
        f'name = "{name}"\nmain = "{name}/main.py"\n\n' + _DEFAULT_TOML, encoding="utf-8")
    (project_dir / name / "__init__.py").write_text("", encoding="utf-8")
    (project_dir / name / "main.py").write_text(_DEFAULT_MAIN_PY, encoding="utf-8")
    (project_dir / "pyproject.toml").write_text(
        f'[project]\nname = "{name}"\nversion = "0.1.0"\n'
        'requires-python = ">=3.14"\n'
        'dependencies = ["simvx-core", "simvx-graphics"]\n',
        encoding="utf-8",
    )
    print(f"Created project {name!r} at {project_dir.resolve()}")
    print(f"  cd {name} && simvx run")
    return 0


def _cmd_lsp(_: argparse.Namespace) -> int:
    from .lsp.server import SimVXLSPServer

    SimVXLSPServer().run()
    return 0


def _cmd_context(_: argparse.Namespace) -> int:
    from .node import Node
    from .project import find_project, load_project

    lines = ["# SimVX Project Context", ""]
    toml_path = find_project()
    if toml_path:
        settings = load_project(toml_path)
        lines += [f"## Project: {settings.name}", f"Main: {settings.main}", ""]

    lines.append("## Available Node Types")
    for name, cls in sorted(Node._registry.items()):
        if not name.startswith("_"):
            bases = " > ".join(c.__name__ for c in cls.__mro__[1:3])
            lines.append(f"- `{name}` ({bases})")
    lines.append("")

    if toml_path:
        project_dir = toml_path.parent
        py_files = sorted(
            p.relative_to(project_dir) for p in project_dir.rglob("*.py")
            if "__pycache__" not in str(p)
        )
        if py_files:
            lines.append("## Project Classes")
            lines += [f"- `{rel}`" for rel in py_files] + [""]

        assets_dir = project_dir / "assets"
        if assets_dir.is_dir():
            asset_files = sorted(
                a.relative_to(project_dir) for a in assets_dir.rglob("*") if a.is_file()
            )
            if asset_files:
                lines.append("## Assets")
                lines += [f"- `{rel}`" for rel in asset_files] + [""]

    print("\n".join(lines))
    return 0


# ---------------------------------------------------------------------------
# Parser assembly
# ---------------------------------------------------------------------------


def _register_builtins(sub: argparse._SubParsersAction) -> None:
    p_run = sub.add_parser("run", help="Run a SimVX project or file")
    p_run.add_argument("file", nargs="?", default=None, help="Python file to run")
    p_run.add_argument("--root-class", dest="root_class", default=None,
                       help="Root Node subclass to run from file")
    p_run.add_argument("--width", type=int, default=None, help="Window width")
    p_run.add_argument("--height", type=int, default=None, help="Window height")
    p_run.set_defaults(func=_cmd_run)

    p_new = sub.add_parser("new", help="Create a new SimVX project")
    p_new.add_argument("name", help="Project name")
    p_new.add_argument("--template", default=None,
                       help="Project template (registered via simvx.project.templates entry points)")
    p_new.set_defaults(func=_cmd_new)

    p_lsp = sub.add_parser("lsp", help="Start SimVX language server (stdio)")
    p_lsp.set_defaults(func=_cmd_lsp)

    p_ctx = sub.add_parser("context", help="Generate AI context markdown")
    p_ctx.set_defaults(func=_cmd_context)


def _register_export(sub: argparse._SubParsersAction) -> None:
    """Build the ``export`` subcommand; its targets are discovered via entry points."""
    p_export = sub.add_parser("export", help="Export a SimVX project for distribution")
    targets = p_export.add_subparsers(dest="target", metavar="TARGET",
                                      help="Export target (e.g. 'web')")
    for ep in entry_points(group="simvx.export.targets"):
        register = ep.load()
        register(targets)

    def _export_default(args: argparse.Namespace) -> int:
        if getattr(args, "target", None) is None:
            p_export.print_help()
            return 1
        return args.func(args)

    p_export.set_defaults(func=_export_default)


def _register_plugins(sub: argparse._SubParsersAction) -> None:
    """Attach every ``simvx.commands`` entry point as a top-level subcommand."""
    for ep in entry_points(group="simvx.commands"):
        register = ep.load()
        register(sub)


[docs] def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="simvx", description="SimVX Game Engine CLI") parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging") sub = parser.add_subparsers(dest="command", metavar="COMMAND") _register_builtins(sub) _register_export(sub) _register_plugins(sub) return parser
[docs] def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.verbose: logging.basicConfig(level=logging.DEBUG, format="%(name)s: %(message)s") else: logging.basicConfig(level=logging.INFO, format="%(message)s") handler = getattr(args, "func", None) if handler is None: parser.print_help() return 0 return int(handler(args) or 0)
if __name__ == "__main__": sys.exit(main())