dominus-cli 2.0.0 → 2.1.1

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.
@@ -1,412 +1,238 @@
1
- --[[
2
- Dominus - Roblox Studio Plugin
3
- AI-powered development agent bridge via WebSocket
1
+ local HttpService = game:GetService("HttpService")
4
2
 
5
- This plugin connects to the Dominus CLI running on your machine,
6
- enabling real-time bidirectional communication for AI-assisted development.
7
- ]]
8
-
9
- local toolbar = plugin:CreateToolbar("Dominus")
10
- local connectButton = toolbar:CreateButton("Connect", "Connect to Dominus CLI agent", "rbxassetid://12974519994")
11
-
12
- local WsClient = require(script.WsClient)
3
+ local EmbeddedBridgeToken = require(script.BridgeConfig)
4
+ local CommandRouter = require(script.CommandRouter)
13
5
  local Protocol = require(script.Protocol)
14
- local Explorer = require(script.Explorer)
15
- local Executor = require(script.Executor)
16
6
  local Watcher = require(script.Watcher)
17
- local Properties = require(script.Properties)
18
- local Reflection = require(script.Reflection)
19
- local TestRunner = require(script.TestRunner)
20
- local UIBuilder = require(script.UIBuilder)
7
+ local WsClient = require(script.WsClient)
8
+
9
+ local PROTOCOL_VERSION = 2
10
+ local DEFAULT_PORT = 18088
11
+ local MAX_MESSAGE_BYTES = 1024 * 1024
12
+
13
+ local toolbar = plugin:CreateToolbar("Dominus 2")
14
+ local connectButton = toolbar:CreateButton(
15
+ "Dominus 2",
16
+ "Connect this Studio session to the Dominus 2 MCP server",
17
+ "rbxassetid://12974519994"
18
+ )
19
+
20
+ local authenticated = false
21
+ local transportOpen = false
22
+ local shouldReconnect = true
23
+ local reconnectScheduled = false
24
+ local reconnectDelay = 1
25
+ local shuttingDown = false
26
+ local connecting = false
27
+
28
+ local function getSetting(name, default)
29
+ local ok, value = pcall(function()
30
+ return plugin:GetSetting(name)
31
+ end)
32
+ if ok and value ~= nil then
33
+ return value
34
+ end
35
+ return default
36
+ end
37
+
38
+ local clientId = getSetting("Dominus2ClientId", nil)
39
+ if type(clientId) ~= "string" then
40
+ clientId = HttpService:GenerateGUID(false)
41
+ pcall(function()
42
+ plugin:SetSetting("Dominus2ClientId", clientId)
43
+ end)
44
+ end
45
+ local bridgeToken = getSetting("Dominus2BridgeToken", nil)
46
+ if type(EmbeddedBridgeToken) == "string" and EmbeddedBridgeToken ~= "__DOMINUS_BRIDGE_TOKEN__" then
47
+ bridgeToken = EmbeddedBridgeToken
48
+ pcall(function()
49
+ plugin:SetSetting("Dominus2BridgeToken", bridgeToken)
50
+ end)
51
+ end
52
+ local port = tonumber(getSetting("Dominus2Port", DEFAULT_PORT)) or DEFAULT_PORT
21
53
 
22
- local BASE_PORT = 18088
23
- local MAX_PORT_SCAN = 10
24
- local activePort = BASE_PORT
25
- local connected = false
26
- local ws = nil
54
+ local function log(message)
55
+ print("[Dominus 2] " .. message)
56
+ end
57
+
58
+ local function sendEnvelope(message)
59
+ local encodedOk, encoded = pcall(Protocol.encode, message)
60
+ if not encodedOk then
61
+ return false, tostring(encoded)
62
+ end
63
+ if #encoded > MAX_MESSAGE_BYTES then
64
+ return false, "Message exceeds one MiB"
65
+ end
66
+ return WsClient.send(encoded)
67
+ end
27
68
 
28
- local function log(msg)
29
- print("[Dominus]", msg)
69
+ local function sendResponse(id, payload)
70
+ return sendEnvelope({ id = id, type = "studio:response", payload = payload, ts = os.time() })
71
+ end
72
+
73
+ local scheduleReconnect
74
+
75
+ local function stopSession()
76
+ authenticated = false
77
+ transportOpen = false
78
+ Watcher.stop()
79
+ connectButton:SetActive(false)
30
80
  end
31
81
 
32
82
  local function handleMessage(rawMessage)
33
- local ok, msg = pcall(Protocol.decode, rawMessage)
34
- if not ok then
35
- warn("[Dominus] Failed to decode message:", msg)
83
+ if #rawMessage > MAX_MESSAGE_BYTES then
84
+ warn("[Dominus 2] Rejected an oversized server message")
85
+ WsClient.disconnect()
86
+ return
87
+ end
88
+ local decodedOk, message = pcall(Protocol.decode, rawMessage)
89
+ if not decodedOk or type(message) ~= "table" then
90
+ warn("[Dominus 2] Rejected an invalid server message")
36
91
  return
37
92
  end
38
93
 
39
- local msgType = msg.type
40
- local payload = msg.payload
41
- local msgId = msg.id
42
-
43
- -- Route message to appropriate handler
44
- if msgType == "cli:get:explorer" then
45
- local tree = Explorer.getTree(payload.rootPath, payload.maxDepth or 3)
46
- WsClient.send(Protocol.encode({
47
- id = msgId,
48
- type = "studio:response",
49
- payload = { tree = tree },
50
- ts = os.clock(),
51
- }))
52
- elseif msgType == "cli:get:script" then
53
- local result = Explorer.getScriptSource(payload.path)
54
- WsClient.send(Protocol.encode({
55
- id = msgId,
56
- type = "studio:response",
57
- payload = result,
58
- ts = os.clock(),
59
- }))
60
- elseif msgType == "cli:set:script" then
61
- local result = Explorer.setScriptSource(payload.path, payload.source)
62
- WsClient.send(Protocol.encode({
63
- id = msgId,
64
- type = "studio:response",
65
- payload = result,
66
- ts = os.clock(),
67
- }))
68
- elseif msgType == "cli:run" then
69
- local result = Executor.run(payload.code)
70
- WsClient.send(Protocol.encode({
71
- id = msgId,
72
- type = "studio:response",
73
- payload = result,
74
- ts = os.clock(),
75
- }))
76
- elseif msgType == "cli:get:properties" then
77
- local compact = payload.compact
78
- if compact == nil then
79
- compact = true
94
+ if message.type == "server:authenticated" then
95
+ local payload = message.payload or {}
96
+ if payload.protocolVersion ~= PROTOCOL_VERSION or type(payload.connectionId) ~= "string" then
97
+ warn("[Dominus 2] Server returned an invalid authentication response")
98
+ WsClient.disconnect()
99
+ return
80
100
  end
81
- local result = Properties.get(payload.path, compact)
82
- WsClient.send(Protocol.encode({
83
- id = msgId,
84
- type = "studio:response",
85
- payload = result,
86
- ts = os.clock(),
87
- }))
88
- elseif msgType == "cli:get:descendants:properties" then
89
- local compact = payload.compact
90
- if compact == nil then
91
- compact = true
92
- end -- default to compact
93
- local result = Properties.getDescendants(payload.path, compact)
94
- WsClient.send(Protocol.encode({
95
- id = msgId,
96
- type = "studio:response",
97
- payload = result,
98
- ts = os.clock(),
99
- }))
100
- elseif msgType == "cli:set:properties" then
101
- local result = Properties.set(payload.path, payload.properties)
102
- WsClient.send(Protocol.encode({
103
- id = msgId,
104
- type = "studio:response",
105
- payload = result,
106
- ts = os.clock(),
107
- }))
108
- elseif msgType == "cli:insert" then
109
- local result = Explorer.insertInstance(payload.className, payload.parentPath, payload.name, payload.properties)
110
- WsClient.send(Protocol.encode({
111
- id = msgId,
112
- type = "studio:response",
113
- payload = result,
114
- ts = os.clock(),
115
- }))
116
- elseif msgType == "cli:delete" then
117
- local result = Explorer.deleteInstance(payload.path)
118
- WsClient.send(Protocol.encode({
119
- id = msgId,
120
- type = "studio:response",
121
- payload = result,
122
- ts = os.clock(),
123
- }))
124
- elseif msgType == "cli:get:selection" then
125
- local Selection = game:GetService("Selection")
126
- local selected = Selection:Get()
127
- local paths = {}
128
- local classNames = {}
129
- for _, obj in selected do
130
- table.insert(paths, Explorer.getPath(obj))
131
- table.insert(classNames, obj.ClassName)
101
+ if type(payload.token) == "string" then
102
+ bridgeToken = payload.token
103
+ pcall(function()
104
+ plugin:SetSetting("Dominus2BridgeToken", bridgeToken)
105
+ end)
132
106
  end
133
- WsClient.send(Protocol.encode({
134
- id = msgId,
135
- type = "studio:response",
136
- payload = { paths = paths, classNames = classNames },
137
- ts = os.clock(),
138
- }))
139
- elseif msgType == "cli:search:scripts" then
140
- local result = Explorer.searchScripts(payload.query, payload.maxResults or 20)
141
- WsClient.send(Protocol.encode({
142
- id = msgId,
143
- type = "studio:response",
144
- payload = result,
145
- ts = os.clock(),
146
- }))
147
- elseif msgType == "cli:get:output" then
148
- local result = Watcher.getRecentOutput(payload.limit or 50, payload.level)
149
- WsClient.send(Protocol.encode({
150
- id = msgId,
151
- type = "studio:response",
152
- payload = result,
153
- ts = os.clock(),
154
- }))
155
- elseif msgType == "cli:run:tests" then
156
- local result = TestRunner.run(payload.testName)
157
- WsClient.send(Protocol.encode({
158
- id = msgId,
159
- type = "studio:response",
160
- payload = result,
161
- ts = os.clock(),
162
- }))
163
- elseif msgType == "cli:test:run" then
164
- -- ExecuteRunModeAsync yields until EndTest is called, so run in a thread
165
- task.spawn(function()
166
- local result = TestRunner.executeRunMode(payload.args)
167
- WsClient.send(Protocol.encode({
168
- id = msgId,
169
- type = "studio:response",
170
- payload = result,
171
- ts = os.clock(),
172
- }))
173
- end)
174
- elseif msgType == "cli:test:play" then
175
- -- ExecutePlayModeAsync yields until EndTest is called, so run in a thread
176
- task.spawn(function()
177
- local result = TestRunner.executePlayMode(payload.args)
178
- WsClient.send(Protocol.encode({
179
- id = msgId,
180
- type = "studio:response",
181
- payload = result,
182
- ts = os.clock(),
183
- }))
184
- end)
185
- elseif msgType == "cli:test:end" then
186
- local result = TestRunner.endTest(payload.value)
187
- WsClient.send(Protocol.encode({
188
- id = msgId,
189
- type = "studio:response",
190
- payload = result,
191
- ts = os.clock(),
192
- }))
193
- elseif msgType == "cli:build:ui" then
194
- local result = UIBuilder.build(payload)
195
- WsClient.send(Protocol.encode({
196
- id = msgId,
197
- type = "studio:response",
198
- payload = result,
199
- ts = os.clock(),
200
- }))
201
- elseif msgType == "cli:get:reflection" then
202
- local result = Reflection.getClassInfo(payload.className)
203
- WsClient.send(Protocol.encode({
204
- id = msgId,
205
- type = "studio:response",
206
- payload = result,
207
- ts = os.clock(),
208
- }))
209
- elseif msgType == "cli:serialize:ui" then
210
- local result = UIBuilder.serialize(payload)
211
- WsClient.send(Protocol.encode({
212
- id = msgId,
213
- type = "studio:response",
214
- payload = result,
215
- ts = os.clock(),
216
- }))
217
- elseif msgType == "cli:move:instance" then
218
- local instance = Explorer.resolveInstance(payload.path)
219
- if not instance then
220
- WsClient.send(Protocol.encode({
221
- id = msgId,
222
- type = "studio:response",
223
- payload = { success = false, error = "Instance not found: " .. payload.path },
224
- ts = os.clock(),
225
- }))
226
- else
227
- local newParent = Explorer.resolveInstance(payload.newParent)
228
- if not newParent then
229
- local svcOk, svc = pcall(function()
230
- return game:GetService(payload.newParent)
231
- end)
232
- if svcOk then
233
- newParent = svc
234
- end
235
- end
236
- if not newParent then
237
- WsClient.send(Protocol.encode({
238
- id = msgId,
239
- type = "studio:response",
240
- payload = { success = false, error = "New parent not found: " .. payload.newParent },
241
- ts = os.clock(),
242
- }))
243
- else
244
- local ChangeHistoryService = game:GetService("ChangeHistoryService")
245
- local recording = ChangeHistoryService:TryBeginRecording("Dominus: Move " .. instance.Name)
246
- local ok, err = pcall(function()
247
- instance.Parent = newParent
248
- end)
249
- if recording then
250
- if ok then
251
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
252
- else
253
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
254
- end
255
- end
256
- if ok then
257
- WsClient.send(Protocol.encode({
258
- id = msgId,
259
- type = "studio:response",
260
- payload = { success = true, newPath = Explorer.getPath(instance) },
261
- ts = os.clock(),
262
- }))
263
- else
264
- WsClient.send(Protocol.encode({
265
- id = msgId,
266
- type = "studio:response",
267
- payload = { success = false, error = "Move failed: " .. tostring(err) },
268
- ts = os.clock(),
269
- }))
270
- end
107
+ authenticated = true
108
+ connectButton:SetActive(true)
109
+ log("Authenticated as Studio session " .. payload.connectionId)
110
+ Watcher.start(function(entry)
111
+ if authenticated then
112
+ sendEnvelope({ id = Protocol.generateId(), type = "studio:output", payload = entry, ts = os.time() })
271
113
  end
272
- end
273
- elseif msgType == "cli:undo" then
274
- local ChangeHistoryService = game:GetService("ChangeHistoryService")
275
- local ok, err = pcall(function()
276
- ChangeHistoryService:Undo()
277
114
  end)
278
- WsClient.send(Protocol.encode({
279
- id = msgId,
280
- type = "studio:response",
281
- payload = ok and { success = true } or { success = false, error = tostring(err) },
282
- ts = os.clock(),
283
- }))
284
- elseif msgType == "cli:redo" then
285
- local ChangeHistoryService = game:GetService("ChangeHistoryService")
286
- local ok, err = pcall(function()
287
- ChangeHistoryService:Redo()
288
- end)
289
- WsClient.send(Protocol.encode({
290
- id = msgId,
291
- type = "studio:response",
292
- payload = ok and { success = true } or { success = false, error = tostring(err) },
293
- ts = os.clock(),
294
- }))
295
- elseif msgType == "cli:bulk:set:properties" then
296
- local result = Properties.bulkSet(payload)
297
- WsClient.send(Protocol.encode({
298
- id = msgId,
299
- type = "studio:response",
300
- payload = result,
301
- ts = os.clock(),
302
- }))
303
- elseif msgType == "cli:find:replace:scripts" then
304
- local result = Explorer.findReplaceScripts(payload)
305
- WsClient.send(Protocol.encode({
306
- id = msgId,
307
- type = "studio:response",
308
- payload = result,
309
- ts = os.clock(),
310
- }))
311
- else
312
- warn("[Dominus] Unknown message type:", msgType)
115
+ return
116
+ end
117
+
118
+ if message.type == "server:auth_rejected" then
119
+ warn("[Dominus 2] Authentication failed. Run dominus-install-plugin and restart Studio.")
120
+ WsClient.disconnect()
121
+ return
313
122
  end
314
- end
315
123
 
316
- local function tryConnectToPort(port)
317
- log("Trying ws://localhost:" .. port .. "...")
124
+ if not authenticated then
125
+ warn("[Dominus 2] Ignored a command before authentication")
126
+ return
127
+ end
128
+ CommandRouter.handle(message, sendResponse)
129
+ end
318
130
 
319
- local success = WsClient.connect("ws://localhost:" .. port, {
131
+ local function connect()
132
+ if shuttingDown or transportOpen then
133
+ return
134
+ end
135
+ if connecting then
136
+ if shouldReconnect then
137
+ scheduleReconnect()
138
+ end
139
+ return
140
+ end
141
+ connecting = true
142
+ reconnectScheduled = false
143
+ local success = WsClient.connect("ws://127.0.0.1:" .. port, {
320
144
  onOpen = function()
321
- connected = true
322
- activePort = port
145
+ transportOpen = true
146
+ reconnectDelay = 1
323
147
  connectButton:SetActive(true)
324
- log("Connected to Dominus on port " .. port .. "!")
325
-
326
- local studioVersion = game:GetService("RobloxPluginGuiService") and "Studio" or "Unknown"
327
- local placeName = "Unknown"
328
- pcall(function()
329
- placeName = game:GetService("MarketplaceService"):GetProductInfo(game.PlaceId).Name
330
- end)
331
-
332
- WsClient.send(Protocol.encode({
148
+ local sent, sendErr = sendEnvelope({
333
149
  id = Protocol.generateId(),
334
- type = "studio:connected",
150
+ type = "studio:hello",
335
151
  payload = {
336
- studioVersion = studioVersion,
152
+ protocolVersion = PROTOCOL_VERSION,
153
+ clientId = clientId,
154
+ token = bridgeToken,
155
+ studioVersion = version(),
337
156
  placeId = game.PlaceId,
338
- placeName = placeName,
157
+ placeName = game.Name,
339
158
  },
340
- ts = os.clock(),
341
- }))
342
-
343
- Watcher.start(function(entry)
344
- if connected then
345
- WsClient.send(Protocol.encode({
346
- id = Protocol.generateId(),
347
- type = "studio:output",
348
- payload = entry,
349
- ts = os.clock(),
350
- }))
351
- end
352
- end)
159
+ ts = os.time(),
160
+ })
161
+ if not sent then
162
+ warn("[Dominus 2] Could not send handshake: " .. tostring(sendErr))
163
+ stopSession()
164
+ WsClient.disconnect()
165
+ end
353
166
  end,
354
-
355
167
  onMessage = function(message)
356
168
  task.spawn(handleMessage, message)
357
169
  end,
358
-
359
170
  onClose = function()
360
- connected = false
361
- connectButton:SetActive(false)
362
- Watcher.stop()
363
- log("Disconnected from Dominus CLI")
171
+ local wasOpen = transportOpen
172
+ stopSession()
173
+ if wasOpen and not shuttingDown then
174
+ log("Studio bridge disconnected")
175
+ end
176
+ if shouldReconnect then
177
+ scheduleReconnect()
178
+ end
364
179
  end,
365
-
366
- onError = function(err)
367
- warn("[Dominus] WebSocket error on port " .. port .. ":", err)
180
+ onError = function(statusCode, errorMessage)
181
+ if transportOpen then
182
+ warn("[Dominus 2] Bridge error " .. tostring(statusCode) .. ": " .. tostring(errorMessage))
183
+ stopSession()
184
+ WsClient.disconnect()
185
+ if shouldReconnect then
186
+ scheduleReconnect()
187
+ end
188
+ end
368
189
  end,
369
- })
370
-
371
- return success
190
+ }, 4)
191
+ connecting = false
192
+ if not success then
193
+ stopSession()
194
+ if shouldReconnect then
195
+ scheduleReconnect()
196
+ end
197
+ end
372
198
  end
373
199
 
374
- local function connect()
375
- if connected then
376
- log("Already connected, disconnecting first...")
377
- WsClient.disconnect()
378
- connected = false
200
+ scheduleReconnect = function()
201
+ if reconnectScheduled or shuttingDown or not shouldReconnect then
202
+ return
379
203
  end
380
-
381
- log("Scanning for Dominus server (ports " .. BASE_PORT .. "-" .. (BASE_PORT + MAX_PORT_SCAN - 1) .. ")...")
382
-
383
- for i = 0, MAX_PORT_SCAN - 1 do
384
- local port = BASE_PORT + i
385
- local success = tryConnectToPort(port)
386
- if success then
387
- return
204
+ reconnectScheduled = true
205
+ local delaySeconds = reconnectDelay
206
+ reconnectDelay = math.min(reconnectDelay * 2, 30)
207
+ task.delay(delaySeconds, function()
208
+ reconnectScheduled = false
209
+ if shouldReconnect and not shuttingDown then
210
+ task.spawn(connect)
388
211
  end
389
- end
390
-
391
- warn("[Dominus] No Dominus server found. Is `dominus` or `dominus mcp` running?")
212
+ end)
392
213
  end
393
214
 
394
215
  connectButton.Click:Connect(function()
395
- if connected then
396
- WsClient.disconnect()
397
- connected = false
398
- connectButton:SetActive(false)
399
- else
216
+ shouldReconnect = true
217
+ reconnectDelay = 1
218
+ stopSession()
219
+ WsClient.disconnect()
220
+ log("Reconnecting to the Dominus 2 MCP server")
221
+ task.spawn(function()
222
+ while connecting and not shuttingDown do
223
+ task.wait()
224
+ end
400
225
  connect()
401
- end
226
+ end)
402
227
  end)
403
228
 
404
- -- Auto-connect on plugin load
405
229
  task.delay(1, function()
406
- connect()
230
+ task.spawn(connect)
407
231
  end)
408
232
 
409
233
  plugin.Unloading:Connect(function()
410
- Watcher.stop()
234
+ shuttingDown = true
235
+ shouldReconnect = false
236
+ stopSession()
411
237
  WsClient.disconnect()
412
238
  end)