"""``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())