dominus-cli 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "dominus-cli",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Dominus 2: a secure, MCP-native Roblox Studio engineering agent bridge.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dominus-mcp": "./dist/mcp.js",
8
- "dominus-install-plugin": "./dist/install-plugin.js"
7
+ "dominus-mcp": "dist/mcp.js",
8
+ "dominus-install-plugin": "dist/install-plugin.js"
9
9
  },
10
- "main": "./dist/mcp.js",
10
+ "main": "dist/mcp.js",
11
11
  "scripts": {
12
12
  "dev": "tsx src/mcp/index.ts",
13
13
  "dev:mcp": "tsx src/mcp/index.ts",
@@ -76,6 +76,6 @@
76
76
  "license": "MIT",
77
77
  "repository": {
78
78
  "type": "git",
79
- "url": "https://github.com/eli/dominus"
79
+ "url": "git+https://github.com/eli/dominus.git"
80
80
  }
81
81
  }
@@ -2,8 +2,12 @@
2
2
  <Item class="Script" referent="0">
3
3
  <Properties>
4
4
  <string name="Name">Dominus</string>
5
- <token name="RunContext">0</token>
6
- <string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
5
+ <token name="RunContext">3</token>
6
+ <string name="Source"><![CDATA[if script.RunContext ~= Enum.RunContext.Plugin then
7
+ return
8
+ end
9
+
10
+ local HttpService = game:GetService("HttpService")
7
11
 
8
12
  local EmbeddedBridgeToken = require(script.BridgeConfig)
9
13
  local CommandRouter = require(script.CommandRouter)
@@ -121,7 +125,7 @@ local function handleMessage(rawMessage)
121
125
  end
122
126
 
123
127
  if message.type == "server:auth_rejected" then
124
- warn("[Dominus 2] Authentication failed. Run dominus-install-plugin and restart Studio.")
128
+ warn("[Dominus 2] Authentication failed. Run dominus-install-plugin to refresh the local plugin.")
125
129
  WsClient.disconnect()
126
130
  return
127
131
  end
@@ -252,9 +256,443 @@ return "__DOMINUS_BRIDGE_TOKEN__"
252
256
  </Properties>
253
257
  </Item>
254
258
  <Item class="ModuleScript" referent="2">
259
+ <Properties>
260
+ <string name="Name">Building</string>
261
+ <string name="Source"><![CDATA[local ChangeHistoryService = game:GetService("ChangeHistoryService")
262
+ local Selection = game:GetService("Selection")
263
+
264
+ local InstanceRegistry = require(script.Parent.InstanceRegistry)
265
+ local ValueCodec = require(script.Parent.ValueCodec)
266
+
267
+ local Building = {}
268
+
269
+ local MAX_ITEMS = 200
270
+ local MAX_PROPERTIES = 100
271
+
272
+ local ALLOWED_PART_CLASSES = {
273
+ Part = true,
274
+ WedgePart = true,
275
+ CornerWedgePart = true,
276
+ TrussPart = true,
277
+ MeshPart = true,
278
+ SpawnLocation = true,
279
+ Seat = true,
280
+ VehicleSeat = true,
281
+ }
282
+
283
+ local function resolve(target, label)
284
+ local instance, err = InstanceRegistry.resolve(target)
285
+ if not instance then
286
+ error(label .. ": " .. tostring(err))
287
+ end
288
+ return instance
289
+ end
290
+
291
+ local function cleanup(instances)
292
+ for _, instance in instances do
293
+ pcall(function()
294
+ if instance.Parent == nil then
295
+ instance:Destroy()
296
+ end
297
+ end)
298
+ end
299
+ end
300
+
301
+ local function record(name, callback, created)
302
+ local recording = ChangeHistoryService:TryBeginRecording(name)
303
+ if not recording then
304
+ return { success = false, error = "Another plugin recording is already active" }
305
+ end
306
+
307
+ local ok, result = pcall(callback)
308
+ if not ok then
309
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
310
+ cleanup(created or {})
311
+ return { success = false, error = tostring(result), rolledBack = true }
312
+ end
313
+
314
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
315
+ result.success = true
316
+ return result
317
+ end
318
+
319
+ local function finiteNumber(value, label)
320
+ assert(type(value) == "number" and value == value and math.abs(value) < math.huge, label .. " must be finite")
321
+ return value
322
+ end
323
+
324
+ local function vector3(value, label, default)
325
+ if value == nil then
326
+ return default
327
+ end
328
+ assert(type(value) == "table", label .. " must be a Vector3 object")
329
+ return Vector3.new(
330
+ finiteNumber(value.X or value.x or value[1], label .. ".x"),
331
+ finiteNumber(value.Y or value.y or value[2], label .. ".y"),
332
+ finiteNumber(value.Z or value.z or value[3], label .. ".z")
333
+ )
334
+ end
335
+
336
+ local function rotation(value, label)
337
+ local degrees = vector3(value, label, Vector3.zero)
338
+ return CFrame.fromOrientation(math.rad(degrees.X), math.rad(degrees.Y), math.rad(degrees.Z))
339
+ end
340
+
341
+ local function setProperties(instance, properties)
342
+ if properties == nil then
343
+ return {}
344
+ end
345
+ assert(type(properties) == "table", "properties must be an object")
346
+ local count = 0
347
+ local applied = {}
348
+ for propertyName, value in properties do
349
+ count += 1
350
+ if count > MAX_PROPERTIES then
351
+ error("At most " .. MAX_PROPERTIES .. " properties are allowed per item")
352
+ end
353
+ applied[propertyName] = ValueCodec.setProperty(instance, propertyName, value)
354
+ end
355
+ return applied
356
+ end
357
+
358
+ local function validateItemCount(items, label, maximum)
359
+ assert(type(items) == "table" and #items > 0, label .. " must contain at least one item")
360
+ assert(#items <= maximum, label .. " is limited to " .. maximum .. " items")
361
+ end
362
+
363
+ local function assertMovable(instance, label)
364
+ assert(instance ~= game and instance.Parent ~= game, label .. " cannot be a DataModel service")
365
+ end
366
+
367
+ function Building.createParts(spec)
368
+ validateItemCount(spec.parts, "parts", MAX_ITEMS)
369
+ local parent = spec.parent and resolve(spec.parent, "parent") or workspace
370
+ local group = nil
371
+ local created = {}
372
+
373
+ return record("Dominus 2: Build parts", function()
374
+ if spec.groupName ~= nil then
375
+ assert(type(spec.groupName) == "string" and #spec.groupName > 0, "groupName must be a non-empty string")
376
+ local groupClass = spec.groupClass or "Model"
377
+ assert(groupClass == "Model" or groupClass == "Folder", "groupClass must be Model or Folder")
378
+ group = Instance.new(groupClass)
379
+ group.Name = spec.groupName
380
+ table.insert(created, group)
381
+ end
382
+
383
+ local parts = {}
384
+ for index, item in spec.parts do
385
+ assert(type(item) == "table", "Part " .. index .. " must be an object")
386
+ assert(type(item.name) == "string" and #item.name > 0, "Part " .. index .. ".name must be a non-empty string")
387
+ local className = item.className or "Part"
388
+ if item.shape == "Wedge" then
389
+ className = "WedgePart"
390
+ elseif item.shape == "CornerWedge" then
391
+ className = "CornerWedgePart"
392
+ end
393
+ assert(ALLOWED_PART_CLASSES[className] == true, "Part " .. index .. " uses an unsupported class: " .. tostring(className))
394
+
395
+ local part = Instance.new(className)
396
+ table.insert(created, part)
397
+ part.Name = item.name
398
+ if part:IsA("BasePart") then
399
+ part.Anchored = item.anchored ~= false
400
+ end
401
+ if item.position then
402
+ part.Position = vector3(item.position, "Part " .. index .. ".position")
403
+ end
404
+ if item.orientation then
405
+ part.Orientation = vector3(item.orientation, "Part " .. index .. ".orientation")
406
+ end
407
+ if item.size then
408
+ part.Size = vector3(item.size, "Part " .. index .. ".size")
409
+ end
410
+ if item.color then
411
+ ValueCodec.setProperty(part, "Color", item.color)
412
+ end
413
+ if item.material then
414
+ ValueCodec.setProperty(part, "Material", item.material)
415
+ end
416
+ if item.transparency ~= nil then
417
+ part.Transparency = finiteNumber(item.transparency, "Part " .. index .. ".transparency")
418
+ end
419
+ if item.canCollide ~= nil then
420
+ assert(type(item.canCollide) == "boolean", "Part " .. index .. ".canCollide must be a boolean")
421
+ part.CanCollide = item.canCollide
422
+ end
423
+ if item.shape and className == "Part" then
424
+ ValueCodec.setProperty(part, "Shape", item.shape)
425
+ end
426
+ setProperties(part, item.properties)
427
+ part.Parent = group or parent
428
+ table.insert(parts, part)
429
+ end
430
+
431
+ if group then
432
+ group.Parent = parent
433
+ end
434
+
435
+ local refs = {}
436
+ for _, part in parts do
437
+ table.insert(refs, InstanceRegistry.toRef(part))
438
+ end
439
+ return {
440
+ created = refs,
441
+ group = group and InstanceRegistry.toRef(group) or nil,
442
+ count = #refs,
443
+ }
444
+ end, created)
445
+ end
446
+
447
+ function Building.cloneInstances(spec)
448
+ validateItemCount(spec.requests, "requests", 100)
449
+ local created = {}
450
+
451
+ return record("Dominus 2: Clone instances", function()
452
+ local clones = {}
453
+ local totalCopies = 0
454
+ for requestIndex, request in spec.requests do
455
+ assert(type(request) == "table", "Clone request " .. requestIndex .. " must be an object")
456
+ local source = resolve(request.target, "Clone request " .. requestIndex .. " target")
457
+ assertMovable(source, "Clone source")
458
+ local parent = request.parent and resolve(request.parent, "Clone request " .. requestIndex .. " parent") or source.Parent
459
+ assert(parent ~= nil, "Clone request " .. requestIndex .. " source has no parent")
460
+ local count = request.count or 1
461
+ assert(type(count) == "number" and count % 1 == 0 and count >= 1 and count <= 50, "count must be an integer from 1 to 50")
462
+ totalCopies += count
463
+ assert(totalCopies <= MAX_ITEMS, "A clone batch is limited to " .. MAX_ITEMS .. " copies")
464
+ local offset = vector3(request.offset, "Clone request " .. requestIndex .. ".offset", Vector3.zero)
465
+ local stepOffset = vector3(request.stepOffset, "Clone request " .. requestIndex .. ".stepOffset", Vector3.zero)
466
+
467
+ for copyIndex = 1, count do
468
+ local clone = source:Clone()
469
+ assert(clone ~= nil, "Clone request " .. requestIndex .. " target is not Archivable")
470
+ table.insert(created, clone)
471
+ if request.name then
472
+ clone.Name = count == 1 and request.name or (request.name .. " " .. copyIndex)
473
+ end
474
+ setProperties(clone, request.properties)
475
+ if clone:IsA("PVInstance") then
476
+ local totalOffset = offset + stepOffset * (copyIndex - 1)
477
+ clone:PivotTo(clone:GetPivot() + totalOffset)
478
+ elseif offset.Magnitude > 0 or stepOffset.Magnitude > 0 then
479
+ error("Clone request " .. requestIndex .. " cannot offset a non-PVInstance")
480
+ end
481
+ clone.Parent = parent
482
+ table.insert(clones, InstanceRegistry.toRef(clone))
483
+ end
484
+ end
485
+ return { clones = clones, count = #clones }
486
+ end, created)
487
+ end
488
+
489
+ local function validateIndependentTargets(targets)
490
+ for leftIndex, left in targets do
491
+ for rightIndex = leftIndex + 1, #targets do
492
+ local right = targets[rightIndex]
493
+ assert(not left:IsAncestorOf(right) and not right:IsAncestorOf(left), "Targets " .. leftIndex .. " and " .. rightIndex .. " overlap")
494
+ end
495
+ end
496
+ end
497
+
498
+ function Building.groupInstances(spec)
499
+ validateItemCount(spec.targets, "targets", 100)
500
+ assert(type(spec.name) == "string" and #spec.name > 0, "name must be a non-empty string")
501
+ local className = spec.className or "Model"
502
+ assert(className == "Model" or className == "Folder", "className must be Model or Folder")
503
+ local targets = {}
504
+ for index, targetRef in spec.targets do
505
+ local target = resolve(targetRef, "Target " .. index)
506
+ assertMovable(target, "Target " .. index)
507
+ table.insert(targets, target)
508
+ end
509
+ validateIndependentTargets(targets)
510
+ local parent = spec.parent and resolve(spec.parent, "parent") or targets[1].Parent
511
+ assert(parent ~= nil, "The first target has no parent")
512
+ local created = {}
513
+
514
+ return record("Dominus 2: Group instances", function()
515
+ local group = Instance.new(className)
516
+ table.insert(created, group)
517
+ group.Name = spec.name
518
+ group.Parent = parent
519
+ for _, target in targets do
520
+ assert(not target:IsAncestorOf(group), "Cannot group an ancestor into its descendant")
521
+ target.Parent = group
522
+ end
523
+ return { group = InstanceRegistry.toRef(group), count = #targets }
524
+ end, created)
525
+ end
526
+
527
+ function Building.ungroupInstances(spec)
528
+ assert(spec.confirm == true, "confirm must be true")
529
+ validateItemCount(spec.targets, "targets", 20)
530
+
531
+ return record("Dominus 2: Ungroup instances", function()
532
+ local moved = {}
533
+ local removed = {}
534
+ for index, targetRef in spec.targets do
535
+ local group = resolve(targetRef, "Group " .. index)
536
+ assert(group:IsA("Model") or group:IsA("Folder"), "Group " .. index .. " must be a Model or Folder")
537
+ assertMovable(group, "Group " .. index)
538
+ local parent = spec.parent and resolve(spec.parent, "parent") or group.Parent
539
+ assert(parent ~= nil, "Group " .. index .. " has no parent")
540
+ for _, child in group:GetChildren() do
541
+ child.Parent = parent
542
+ table.insert(moved, InstanceRegistry.toRef(child))
543
+ end
544
+ table.insert(removed, { name = group.Name, className = group.ClassName })
545
+ group:Destroy()
546
+ end
547
+ return { moved = moved, removedGroups = removed, count = #moved }
548
+ end)
549
+ end
550
+
551
+ local function absolutePivot(current, operation)
552
+ if operation.cframe then
553
+ return ValueCodec.decodeForCurrent(current, operation.cframe)
554
+ end
555
+ local position = vector3(operation.position, "position", current.Position)
556
+ local x, y, z = current:ToOrientation()
557
+ if operation.orientation then
558
+ local degrees = vector3(operation.orientation, "orientation")
559
+ x, y, z = math.rad(degrees.X), math.rad(degrees.Y), math.rad(degrees.Z)
560
+ end
561
+ return CFrame.new(position) * CFrame.fromOrientation(x, y, z)
562
+ end
563
+
564
+ function Building.transformInstances(spec)
565
+ validateItemCount(spec.operations, "operations", 100)
566
+
567
+ return record("Dominus 2: Transform instances", function()
568
+ local results = {}
569
+ for index, operation in spec.operations do
570
+ local target = resolve(operation.target, "Operation " .. index .. " target")
571
+ assert(target:IsA("PVInstance"), "Operation " .. index .. " target must be a Model or BasePart")
572
+ local current = target:GetPivot()
573
+ local mode = operation.mode or "absolute"
574
+ local nextPivot
575
+ if mode == "absolute" then
576
+ nextPivot = absolutePivot(current, operation)
577
+ elseif mode == "relative" then
578
+ local offset = vector3(operation.offset, "Operation " .. index .. ".offset", Vector3.zero)
579
+ local rotate = rotation(operation.rotationOffset, "Operation " .. index .. ".rotationOffset")
580
+ if operation.space == "local" then
581
+ nextPivot = current * CFrame.new(offset) * rotate
582
+ else
583
+ nextPivot = CFrame.new(offset) * current * rotate
584
+ end
585
+ else
586
+ error("Operation " .. index .. ".mode must be absolute or relative")
587
+ end
588
+ target:PivotTo(nextPivot)
589
+ table.insert(results, { target = InstanceRegistry.toRef(target), pivot = ValueCodec.encode(target:GetPivot()) })
590
+ end
591
+ return { transformed = results, count = #results }
592
+ end)
593
+ end
594
+
595
+ function Building.createWelds(spec)
596
+ validateItemCount(spec.welds, "welds", MAX_ITEMS)
597
+ local created = {}
598
+
599
+ return record("Dominus 2: Create welds", function()
600
+ local welds = {}
601
+ for index, item in spec.welds do
602
+ local part0 = resolve(item.part0, "Weld " .. index .. " part0")
603
+ local part1 = resolve(item.part1, "Weld " .. index .. " part1")
604
+ assert(part0:IsA("BasePart") and part1:IsA("BasePart"), "Weld " .. index .. " endpoints must be BaseParts")
605
+ assert(part0 ~= part1, "Weld " .. index .. " endpoints must be different")
606
+ local parent = item.parent and resolve(item.parent, "Weld " .. index .. " parent") or part0
607
+ local weld = Instance.new("WeldConstraint")
608
+ table.insert(created, weld)
609
+ weld.Name = item.name or (part0.Name .. "_to_" .. part1.Name)
610
+ weld.Part0 = part0
611
+ weld.Part1 = part1
612
+ weld.Parent = parent
613
+ table.insert(welds, InstanceRegistry.toRef(weld))
614
+ end
615
+ return { welds = welds, count = #welds }
616
+ end, created)
617
+ end
618
+
619
+ local function overlapParams(spec)
620
+ local params = OverlapParams.new()
621
+ local maxParts = spec.maxParts or 200
622
+ assert(type(maxParts) == "number" and maxParts % 1 == 0 and maxParts >= 1 and maxParts <= 500, "maxParts must be an integer from 1 to 500")
623
+ params.MaxParts = maxParts
624
+ params.RespectCanCollide = spec.respectCanCollide == true
625
+ if spec.filter and #spec.filter > 0 then
626
+ local instances = {}
627
+ for index, targetRef in spec.filter do
628
+ table.insert(instances, resolve(targetRef, "Filter " .. index))
629
+ end
630
+ params.FilterDescendantsInstances = instances
631
+ params.FilterType = spec.filterType == "Include" and Enum.RaycastFilterType.Include or Enum.RaycastFilterType.Exclude
632
+ end
633
+ return params
634
+ end
635
+
636
+ function Building.queryParts(spec)
637
+ local root = spec.root and resolve(spec.root, "root") or workspace
638
+ assert(root:IsA("WorldRoot"), "root must be Workspace or a WorldModel")
639
+ local mode = spec.mode or "box"
640
+ local found
641
+ if mode == "box" then
642
+ local position = vector3(spec.position, "position")
643
+ local size = vector3(spec.size, "size")
644
+ assert(size.X > 0 and size.Y > 0 and size.Z > 0, "size components must be greater than zero")
645
+ local box = CFrame.new(position) * rotation(spec.orientation, "orientation")
646
+ found = root:GetPartBoundsInBox(box, size, overlapParams(spec))
647
+ elseif mode == "radius" then
648
+ local position = vector3(spec.position, "position")
649
+ local radius = finiteNumber(spec.radius, "radius")
650
+ assert(radius > 0, "radius must be greater than zero")
651
+ found = root:GetPartBoundsInRadius(position, radius, overlapParams(spec))
652
+ else
653
+ error("mode must be box or radius")
654
+ end
655
+
656
+ table.sort(found, function(left, right)
657
+ return InstanceRegistry.getDisplayPath(left) < InstanceRegistry.getDisplayPath(right)
658
+ end)
659
+ local parts = {}
660
+ for _, part in found do
661
+ table.insert(parts, {
662
+ name = part.Name,
663
+ className = part.ClassName,
664
+ ref = InstanceRegistry.toRef(part),
665
+ position = ValueCodec.encode(part.Position),
666
+ size = ValueCodec.encode(part.Size),
667
+ })
668
+ end
669
+ return { success = true, parts = parts, count = #parts, mode = mode }
670
+ end
671
+
672
+ function Building.setSelection(spec)
673
+ assert(type(spec.targets) == "table", "targets must be an array")
674
+ assert(#spec.targets <= 200, "At most 200 instances can be selected")
675
+ local targets = {}
676
+ for index, targetRef in spec.targets do
677
+ table.insert(targets, resolve(targetRef, "Target " .. index))
678
+ end
679
+ Selection:Set(targets)
680
+ local selected = {}
681
+ for _, target in targets do
682
+ table.insert(selected, InstanceRegistry.toRef(target))
683
+ end
684
+ return { success = true, selection = selected, count = #selected }
685
+ end
686
+
687
+ return Building
688
+ ]]></string>
689
+ </Properties>
690
+ </Item>
691
+ <Item class="ModuleScript" referent="3">
255
692
  <Properties>
256
693
  <string name="Name">CommandRouter</string>
257
- <string name="Source"><![CDATA[local Explorer = require(script.Parent.Explorer)
694
+ <string name="Source"><![CDATA[local Building = require(script.Parent.Building)
695
+ local Explorer = require(script.Parent.Explorer)
258
696
  local InstanceRegistry = require(script.Parent.InstanceRegistry)
259
697
  local Mutation = require(script.Parent.Mutation)
260
698
  local Properties = require(script.Parent.Properties)
@@ -293,6 +731,30 @@ local handlers = {
293
731
  ["studio:v2:apply"] = function(payload)
294
732
  return Mutation.apply(payload)
295
733
  end,
734
+ ["studio:v2:create_parts"] = function(payload)
735
+ return Building.createParts(payload)
736
+ end,
737
+ ["studio:v2:clone_instances"] = function(payload)
738
+ return Building.cloneInstances(payload)
739
+ end,
740
+ ["studio:v2:group_instances"] = function(payload)
741
+ return Building.groupInstances(payload)
742
+ end,
743
+ ["studio:v2:ungroup_instances"] = function(payload)
744
+ return Building.ungroupInstances(payload)
745
+ end,
746
+ ["studio:v2:transform_instances"] = function(payload)
747
+ return Building.transformInstances(payload)
748
+ end,
749
+ ["studio:v2:create_welds"] = function(payload)
750
+ return Building.createWelds(payload)
751
+ end,
752
+ ["studio:v2:query_parts"] = function(payload)
753
+ return Building.queryParts(payload)
754
+ end,
755
+ ["studio:v2:set_selection"] = function(payload)
756
+ return Building.setSelection(payload)
757
+ end,
296
758
  ["studio:v2:delete"] = function(payload)
297
759
  return Mutation.delete(payload)
298
760
  end,
@@ -351,7 +813,7 @@ return CommandRouter
351
813
  ]]></string>
352
814
  </Properties>
353
815
  </Item>
354
- <Item class="ModuleScript" referent="3">
816
+ <Item class="ModuleScript" referent="4">
355
817
  <Properties>
356
818
  <string name="Name">Explorer</string>
357
819
  <string name="Source"><![CDATA[--[[
@@ -887,7 +1349,7 @@ return Explorer
887
1349
  ]]></string>
888
1350
  </Properties>
889
1351
  </Item>
890
- <Item class="ModuleScript" referent="4">
1352
+ <Item class="ModuleScript" referent="5">
891
1353
  <Properties>
892
1354
  <string name="Name">InstanceRegistry</string>
893
1355
  <string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
@@ -1015,7 +1477,7 @@ return InstanceRegistry
1015
1477
  ]]></string>
1016
1478
  </Properties>
1017
1479
  </Item>
1018
- <Item class="ModuleScript" referent="5">
1480
+ <Item class="ModuleScript" referent="6">
1019
1481
  <Properties>
1020
1482
  <string name="Name">Mutation</string>
1021
1483
  <string name="Source"><![CDATA[local ChangeHistoryService = game:GetService("ChangeHistoryService")
@@ -1064,6 +1526,7 @@ function Mutation.apply(spec)
1064
1526
  end
1065
1527
 
1066
1528
  local results = {}
1529
+ local created = {}
1067
1530
  local ok, err = pcall(function()
1068
1531
  for index, operation in operations do
1069
1532
  assert(type(operation) == "table", "Operation " .. index .. " must be an object")
@@ -1079,12 +1542,34 @@ function Mutation.apply(spec)
1079
1542
  assert(type(operation.className) == "string", "create.className must be a string")
1080
1543
  assert(type(operation.name) == "string", "create.name must be a string")
1081
1544
  local instance = Instance.new(operation.className)
1545
+ table.insert(created, instance)
1082
1546
  instance.Name = operation.name
1083
1547
  if operation.properties then
1084
1548
  setProperties(instance, operation.properties)
1085
1549
  end
1086
1550
  instance.Parent = parent
1087
1551
  table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(instance) })
1552
+ elseif operation.op == "clone" then
1553
+ local target = resolve(operation.target, "Operation " .. index .. " target")
1554
+ assert(target ~= game and target.Parent ~= game, "Services cannot be cloned")
1555
+ local parent = operation.parent and resolve(operation.parent, "Operation " .. index .. " parent") or target.Parent
1556
+ assert(parent ~= nil, "Clone target has no parent")
1557
+ local clone = target:Clone()
1558
+ assert(clone ~= nil, "Clone target is not Archivable")
1559
+ table.insert(created, clone)
1560
+ if operation.name then
1561
+ clone.Name = operation.name
1562
+ end
1563
+ if operation.properties then
1564
+ setProperties(clone, operation.properties)
1565
+ end
1566
+ if operation.offset then
1567
+ assert(clone:IsA("PVInstance"), "Only Models and BaseParts can be spatially offset")
1568
+ local offset = ValueCodec.decodeForCurrent(Vector3.zero, operation.offset)
1569
+ clone:PivotTo(clone:GetPivot() + offset)
1570
+ end
1571
+ clone.Parent = parent
1572
+ table.insert(results, { op = operation.op, created = InstanceRegistry.toRef(clone) })
1088
1573
  elseif operation.op == "move" then
1089
1574
  local target = resolve(operation.target, "Operation " .. index .. " target")
1090
1575
  local parent = resolve(operation.parent, "Operation " .. index .. " parent")
@@ -1099,6 +1584,13 @@ function Mutation.apply(spec)
1099
1584
 
1100
1585
  if not ok then
1101
1586
  ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
1587
+ for _, instance in created do
1588
+ pcall(function()
1589
+ if instance.Parent == nil then
1590
+ instance:Destroy()
1591
+ end
1592
+ end)
1593
+ end
1102
1594
  return { success = false, error = tostring(err), rolledBack = true }
1103
1595
  end
1104
1596
 
@@ -1156,7 +1648,7 @@ return Mutation
1156
1648
  ]]></string>
1157
1649
  </Properties>
1158
1650
  </Item>
1159
- <Item class="ModuleScript" referent="6">
1651
+ <Item class="ModuleScript" referent="7">
1160
1652
  <Properties>
1161
1653
  <string name="Name">Properties</string>
1162
1654
  <string name="Source"><![CDATA[--[[
@@ -2021,7 +2513,7 @@ return Properties
2021
2513
  ]]></string>
2022
2514
  </Properties>
2023
2515
  </Item>
2024
- <Item class="ModuleScript" referent="7">
2516
+ <Item class="ModuleScript" referent="8">
2025
2517
  <Properties>
2026
2518
  <string name="Name">Protocol</string>
2027
2519
  <string name="Source"><![CDATA[--[[
@@ -2057,7 +2549,7 @@ return Protocol
2057
2549
  ]]></string>
2058
2550
  </Properties>
2059
2551
  </Item>
2060
- <Item class="ModuleScript" referent="8">
2552
+ <Item class="ModuleScript" referent="9">
2061
2553
  <Properties>
2062
2554
  <string name="Name">Reflection</string>
2063
2555
  <string name="Source"><![CDATA[local ReflectionService = game:GetService("ReflectionService")
@@ -2232,7 +2724,7 @@ return Reflection
2232
2724
  ]]></string>
2233
2725
  </Properties>
2234
2726
  </Item>
2235
- <Item class="ModuleScript" referent="9">
2727
+ <Item class="ModuleScript" referent="10">
2236
2728
  <Properties>
2237
2729
  <string name="Name">TestRunner</string>
2238
2730
  <string name="Source"><![CDATA[local StudioTestService = game:GetService("StudioTestService")
@@ -2307,7 +2799,7 @@ return TestRunner
2307
2799
  ]]></string>
2308
2800
  </Properties>
2309
2801
  </Item>
2310
- <Item class="ModuleScript" referent="10">
2802
+ <Item class="ModuleScript" referent="11">
2311
2803
  <Properties>
2312
2804
  <string name="Name">UIBuilder</string>
2313
2805
  <string name="Source"><![CDATA[--[[
@@ -3170,7 +3662,7 @@ return UIBuilder
3170
3662
  ]]></string>
3171
3663
  </Properties>
3172
3664
  </Item>
3173
- <Item class="ModuleScript" referent="11">
3665
+ <Item class="ModuleScript" referent="12">
3174
3666
  <Properties>
3175
3667
  <string name="Name">ValueCodec</string>
3176
3668
  <string name="Source"><![CDATA[local InstanceRegistry = require(script.Parent.InstanceRegistry)
@@ -3407,7 +3899,7 @@ return ValueCodec
3407
3899
  ]]></string>
3408
3900
  </Properties>
3409
3901
  </Item>
3410
- <Item class="ModuleScript" referent="12">
3902
+ <Item class="ModuleScript" referent="13">
3411
3903
  <Properties>
3412
3904
  <string name="Name">Watcher</string>
3413
3905
  <string name="Source"><![CDATA[--[[
@@ -3498,7 +3990,7 @@ return Watcher
3498
3990
  ]]></string>
3499
3991
  </Properties>
3500
3992
  </Item>
3501
- <Item class="ModuleScript" referent="13">
3993
+ <Item class="ModuleScript" referent="14">
3502
3994
  <Properties>
3503
3995
  <string name="Name">WsClient</string>
3504
3996
  <string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "Dominus",
3
3
  "tree": {
4
- "$path": "src"
4
+ "$path": "src",
5
+ "$properties": {
6
+ "RunContext": "Plugin"
7
+ }
5
8
  }
6
9
  }