Join the RoClicks Discord
RoClicks

stage_rbxmx.py

Python, 89 lines from Building overview

Download stage_rbxmx.py
#!/usr/bin/env python3
"""Stage Luau files for a safe per-module push into Roblox Studio.

Each argument is   <StudioPath>=<file.luau>[@<base.luau> | @NEW]

  StudioPath  dot path of the target script, e.g. ReplicatedStorage.Game.Shared.Layers
              (the first segment is a service name)
  file.luau   the new source you want live
  @base.luau  the Studio source you pulled and edited on top of (base-compare)
  @NEW        the script must NOT exist yet

Output: one .rbxmx holding Folder <stage name> with a ModuleScript per file. Its Name is the target
path; a child ModuleScript "Base" (or StringValue "New") carries the base. Import the file into
ServerStorage (an import-from-disk tool, or drag it in), then run stage_apply.luau in Studio. That
script refuses the WHOLE push if any live source has changed since its base, so you never overwrite
a collaborator's live edit.

Why a model file instead of a "set script source" tool call: some MCP transports turn the two
characters \\n inside string literals into real newlines, which breaks the script with
"Malformed string" while the push reports success. An .rbxmx goes from disk to Studio untouched,
and the source never passes through the agent's context a second time.

Usage:
  python3 stage_rbxmx.py [-o out.rbxmx] [-n StageName] Path.To.Script=src/x.luau@pulled/x.luau ...
"""
import argparse
import sys
from pathlib import Path
from xml.sax.saxutils import escape


def cdata(s: str) -> str:
    # "]]>" cannot appear inside CDATA; split it across two sections
    return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>"


class Refs:
    def __init__(self):
        self.n = 0

    def __call__(self) -> str:
        self.n += 1
        return f"RBX{self.n}"


def module(ref, name, source, children=""):
    return (f'<Item class="ModuleScript" referent="{ref()}"><Properties>'
            f'<string name="Name">{escape(name)}</string>'
            f'<ProtectedString name="Source">{cdata(source)}</ProtectedString>'
            f'</Properties>{children}</Item>')


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("items", nargs="+", help="StudioPath=file.luau[@base.luau|@NEW]")
    ap.add_argument("-o", "--out", default="build/Stage.rbxmx")
    ap.add_argument("-n", "--name", default="Stage", help="folder name; must match STAGE in stage_apply.luau")
    args = ap.parse_args()

    ref = Refs()
    root = ref()
    items = []
    for arg in args.items:
        if "=" not in arg:
            sys.exit(f"bad item (want Path=file): {arg}")
        target, spec = arg.split("=", 1)
        base = None
        if "@" in spec:
            spec, base = spec.split("@", 1)
        src = Path(spec).read_text()
        kid = ""
        if base == "NEW":
            kid = (f'<Item class="StringValue" referent="{ref()}"><Properties>'
                   f'<string name="Name">New</string></Properties></Item>')
        elif base:
            kid = module(ref, "Base", Path(base).read_text())
        items.append(module(ref, target, src, kid))

    out = (f'<roblox version="4"><Item class="Folder" referent="{root}"><Properties>'
           f'<string name="Name">{escape(args.name)}</string></Properties>'
           + "".join(items) + "</Item></roblox>")
    Path(args.out).parent.mkdir(parents=True, exist_ok=True)
    Path(args.out).write_text(out)
    print(f"staged {len(items)} file(s) -> {args.out}")


if __name__ == "__main__":
    main()