dominus-cli 0.6.0 → 2.1.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.
@@ -1,412 +1,225 @@
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 CommandRouter = require(script.CommandRouter)
13
4
  local Protocol = require(script.Protocol)
14
- local Explorer = require(script.Explorer)
15
- local Executor = require(script.Executor)
16
5
  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)
6
+ local WsClient = require(script.WsClient)
7
+
8
+ local PROTOCOL_VERSION = 2
9
+ local DEFAULT_PORT = 18088
10
+ local MAX_MESSAGE_BYTES = 1024 * 1024
11
+
12
+ local toolbar = plugin:CreateToolbar("Dominus 2")
13
+ local connectButton = toolbar:CreateButton(
14
+ "Dominus 2",
15
+ "Connect this Studio session to the Dominus 2 MCP server",
16
+ "rbxassetid://12974519994"
17
+ )
18
+
19
+ local authenticated = false
20
+ local transportOpen = false
21
+ local shouldReconnect = true
22
+ local reconnectScheduled = false
23
+ local reconnectDelay = 1
24
+ local shuttingDown = false
25
+
26
+ local function getSetting(name, default)
27
+ local ok, value = pcall(function()
28
+ return plugin:GetSetting(name)
29
+ end)
30
+ if ok and value ~= nil then
31
+ return value
32
+ end
33
+ return default
34
+ end
35
+
36
+ local clientId = getSetting("Dominus2ClientId", nil)
37
+ if type(clientId) ~= "string" then
38
+ clientId = HttpService:GenerateGUID(false)
39
+ pcall(function()
40
+ plugin:SetSetting("Dominus2ClientId", clientId)
41
+ end)
42
+ end
43
+ local bridgeToken = getSetting("Dominus2BridgeToken", nil)
44
+ local port = tonumber(getSetting("Dominus2Port", DEFAULT_PORT)) or DEFAULT_PORT
21
45
 
22
- local BASE_PORT = 18088
23
- local MAX_PORT_SCAN = 10
24
- local activePort = BASE_PORT
25
- local connected = false
26
- local ws = nil
46
+ local function log(message)
47
+ print("[Dominus 2] " .. message)
48
+ end
49
+
50
+ local function sendEnvelope(message)
51
+ local encodedOk, encoded = pcall(Protocol.encode, message)
52
+ if not encodedOk then
53
+ return false, tostring(encoded)
54
+ end
55
+ if #encoded > MAX_MESSAGE_BYTES then
56
+ return false, "Message exceeds one MiB"
57
+ end
58
+ return WsClient.send(encoded)
59
+ end
27
60
 
28
- local function log(msg)
29
- print("[Dominus]", msg)
61
+ local function sendResponse(id, payload)
62
+ return sendEnvelope({ id = id, type = "studio:response", payload = payload, ts = os.time() })
63
+ end
64
+
65
+ local scheduleReconnect
66
+
67
+ local function stopSession()
68
+ authenticated = false
69
+ transportOpen = false
70
+ Watcher.stop()
71
+ connectButton:SetActive(false)
30
72
  end
31
73
 
32
74
  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)
75
+ if #rawMessage > MAX_MESSAGE_BYTES then
76
+ warn("[Dominus 2] Rejected an oversized server message")
77
+ WsClient.disconnect()
78
+ return
79
+ end
80
+ local decodedOk, message = pcall(Protocol.decode, rawMessage)
81
+ if not decodedOk or type(message) ~= "table" then
82
+ warn("[Dominus 2] Rejected an invalid server message")
36
83
  return
37
84
  end
38
85
 
39
- local msgType = msg.type
40
- local payload = msg.payload
41
- local msgId = msg.id
86
+ if message.type == "server:pairing_required" then
87
+ local payload = message.payload or {}
88
+ log(
89
+ "Pairing required. In your MCP client call dominus_pair_studio with connectionId "
90
+ .. tostring(payload.connectionId)
91
+ .. " and code "
92
+ .. tostring(payload.pairingCode)
93
+ )
94
+ return
95
+ end
42
96
 
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
97
+ if message.type == "server:authenticated" then
98
+ local payload = message.payload or {}
99
+ if payload.protocolVersion ~= PROTOCOL_VERSION or type(payload.connectionId) ~= "string" then
100
+ warn("[Dominus 2] Server returned an invalid authentication response")
101
+ WsClient.disconnect()
102
+ return
80
103
  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)
104
+ if type(payload.token) == "string" then
105
+ bridgeToken = payload.token
106
+ pcall(function()
107
+ plugin:SetSetting("Dominus2BridgeToken", bridgeToken)
108
+ end)
132
109
  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
110
+ authenticated = true
111
+ connectButton:SetActive(true)
112
+ log("Authenticated as Studio session " .. payload.connectionId)
113
+ Watcher.start(function(entry)
114
+ if authenticated then
115
+ sendEnvelope({ id = Protocol.generateId(), type = "studio:output", payload = entry, ts = os.time() })
271
116
  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
- 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
117
  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)
118
+ return
313
119
  end
314
- end
315
120
 
316
- local function tryConnectToPort(port)
317
- log("Trying ws://localhost:" .. port .. "...")
121
+ if message.type == "server:auth_rejected" then
122
+ warn("[Dominus 2] Authentication rejected: " .. tostring(message.payload and message.payload.error))
123
+ WsClient.disconnect()
124
+ return
125
+ end
318
126
 
319
- local success = WsClient.connect("ws://localhost:" .. port, {
127
+ if not authenticated then
128
+ warn("[Dominus 2] Ignored a command before authentication")
129
+ return
130
+ end
131
+ CommandRouter.handle(message, sendResponse)
132
+ end
133
+
134
+ local function connect()
135
+ if shuttingDown or transportOpen then
136
+ return
137
+ end
138
+ reconnectScheduled = false
139
+ local success = WsClient.connect("ws://127.0.0.1:" .. port, {
320
140
  onOpen = function()
321
- connected = true
322
- activePort = port
141
+ transportOpen = true
142
+ reconnectDelay = 1
323
143
  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({
144
+ local sent, sendErr = sendEnvelope({
333
145
  id = Protocol.generateId(),
334
- type = "studio:connected",
146
+ type = "studio:hello",
335
147
  payload = {
336
- studioVersion = studioVersion,
148
+ protocolVersion = PROTOCOL_VERSION,
149
+ clientId = clientId,
150
+ token = bridgeToken,
151
+ studioVersion = version(),
337
152
  placeId = game.PlaceId,
338
- placeName = placeName,
153
+ placeName = game.Name,
339
154
  },
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)
155
+ ts = os.time(),
156
+ })
157
+ if not sent then
158
+ warn("[Dominus 2] Could not send handshake: " .. tostring(sendErr))
159
+ end
353
160
  end,
354
-
355
161
  onMessage = function(message)
356
162
  task.spawn(handleMessage, message)
357
163
  end,
358
-
359
164
  onClose = function()
360
- connected = false
361
- connectButton:SetActive(false)
362
- Watcher.stop()
363
- log("Disconnected from Dominus CLI")
165
+ local wasOpen = transportOpen
166
+ stopSession()
167
+ if wasOpen and not shuttingDown then
168
+ log("Studio bridge disconnected")
169
+ end
170
+ if shouldReconnect then
171
+ scheduleReconnect()
172
+ end
364
173
  end,
365
-
366
- onError = function(err)
367
- warn("[Dominus] WebSocket error on port " .. port .. ":", err)
174
+ onError = function(statusCode, errorMessage)
175
+ if transportOpen then
176
+ warn("[Dominus 2] Bridge error " .. tostring(statusCode) .. ": " .. tostring(errorMessage))
177
+ end
368
178
  end,
369
- })
370
-
371
- return success
179
+ }, 4)
180
+ if not success then
181
+ stopSession()
182
+ if shouldReconnect then
183
+ scheduleReconnect()
184
+ end
185
+ end
372
186
  end
373
187
 
374
- local function connect()
375
- if connected then
376
- log("Already connected, disconnecting first...")
377
- WsClient.disconnect()
378
- connected = false
188
+ scheduleReconnect = function()
189
+ if reconnectScheduled or shuttingDown or not shouldReconnect then
190
+ return
379
191
  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
192
+ reconnectScheduled = true
193
+ local delaySeconds = reconnectDelay
194
+ reconnectDelay = math.min(reconnectDelay * 2, 30)
195
+ task.delay(delaySeconds, function()
196
+ reconnectScheduled = false
197
+ if shouldReconnect and not shuttingDown then
198
+ task.spawn(connect)
388
199
  end
389
- end
390
-
391
- warn("[Dominus] No Dominus server found. Is `dominus` or `dominus mcp` running?")
200
+ end)
392
201
  end
393
202
 
394
203
  connectButton.Click:Connect(function()
395
- if connected then
204
+ if transportOpen or WsClient.isConnected() then
205
+ shouldReconnect = false
396
206
  WsClient.disconnect()
397
- connected = false
398
- connectButton:SetActive(false)
207
+ stopSession()
208
+ log("Automatic connection disabled; click Dominus 2 to reconnect")
399
209
  else
400
- connect()
210
+ shouldReconnect = true
211
+ reconnectDelay = 1
212
+ task.spawn(connect)
401
213
  end
402
214
  end)
403
215
 
404
- -- Auto-connect on plugin load
405
216
  task.delay(1, function()
406
- connect()
217
+ task.spawn(connect)
407
218
  end)
408
219
 
409
220
  plugin.Unloading:Connect(function()
410
- Watcher.stop()
221
+ shuttingDown = true
222
+ shouldReconnect = false
223
+ stopSession()
411
224
  WsClient.disconnect()
412
225
  end)