#!/usr/bin/env python3 """Stage Luau files for a safe per-module push into Roblox Studio. Each argument is =[@ | @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 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 "", "]]]]>") + "]]>" 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'' f'{escape(name)}' f'{cdata(source)}' f'{children}') 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'' f'New') elif base: kid = module(ref, "Base", Path(base).read_text()) items.append(module(ref, target, src, kid)) out = (f'' f'{escape(args.name)}' + "".join(items) + "") 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()