virtualmatter 0.1.0 → 0.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/README.md +102 -34
- package/dist/agentfiles.js +114 -0
- package/dist/api.js +114 -0
- package/dist/auth.js +10 -4
- package/dist/client.js +317 -0
- package/dist/config.js +23 -0
- package/dist/files.js +53 -5
- package/dist/ignore.js +22 -1
- package/dist/index.js +229 -52
- package/dist/mcp.js +157 -26
- package/dist/pull.js +58 -37
- package/dist/resolve.js +65 -0
- package/dist/urls.js +80 -25
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -3,29 +3,59 @@
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { resolveSession, sessionBaseUrl,
|
|
7
|
-
import { clearCredentials, loadCredentials, pollForToken, saveCredentials, startDeviceFlow, } from "./auth.js";
|
|
6
|
+
import { createProject, createWebsiteBuild, getWebsiteBuild, getEmbed, defaultRegion, editUrl, fetchNativeClientCatalog, listProjects, playUrl, resolveSession, sessionBaseUrl, whoami, } from "./api.js";
|
|
7
|
+
import { NotLoggedInError, clearCredentials, loadCredentials, pollForToken, saveCredentials, startDeviceFlow, } from "./auth.js";
|
|
8
8
|
import { openInBrowser } from "./browser.js";
|
|
9
|
+
import { detectPlatform, ensureClientInstalled, launchClient, seedNativeAuth } from "./client.js";
|
|
10
|
+
import { apiBase, packageVersion } from "./config.js";
|
|
9
11
|
import { FilesClient } from "./files.js";
|
|
10
|
-
import { runMcpServer
|
|
12
|
+
import { runMcpServer } from "./mcp.js";
|
|
11
13
|
import { pullCommand } from "./pull.js";
|
|
12
|
-
import {
|
|
14
|
+
import { NeedsChoiceError, formatProjectList, makeFramingOf, resolveImplicitTarget, resolveTarget, } from "./resolve.js";
|
|
15
|
+
import { loadState, requireState } from "./state.js";
|
|
13
16
|
import { SyncEngine, runSyncLoop } from "./sync.js";
|
|
14
|
-
import { parseTarget, PROJECT_URL_HELP } from "./urls.js";
|
|
15
17
|
const program = new Command();
|
|
16
18
|
program
|
|
17
19
|
.name("virtualmatter")
|
|
18
|
-
.description("Build with Virtual Matter from your terminal:
|
|
19
|
-
.version(
|
|
20
|
+
.description("Build with Virtual Matter from your terminal or coding agent: list and create worlds, mirror their files, live-sync edits, run Lua, capture screenshots, open the native client, and expose it all over MCP.")
|
|
21
|
+
.version(packageVersion());
|
|
20
22
|
function fail(message) {
|
|
21
23
|
console.error(message);
|
|
22
24
|
process.exit(1);
|
|
23
25
|
}
|
|
26
|
+
/** The device-code sign-in, shared by `login` and the automatic first-use sign-in. */
|
|
27
|
+
async function signIn() {
|
|
28
|
+
const { auth, tokenEndpoint } = await startDeviceFlow();
|
|
29
|
+
const url = auth.verification_uri_complete ?? auth.verification_uri;
|
|
30
|
+
console.log(`Sign in with Virtual Matter: open ${url}`);
|
|
31
|
+
console.log(`and enter the code: ${auth.user_code}`);
|
|
32
|
+
console.log("(waiting for you to approve it in the browser ...)");
|
|
33
|
+
openInBrowser(url);
|
|
34
|
+
const creds = await pollForToken(tokenEndpoint, auth);
|
|
35
|
+
saveCredentials(creds);
|
|
36
|
+
const me = await whoami();
|
|
37
|
+
console.log(`Signed in as ${me.username ?? me.email ?? me.id}.`);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run a command. Not signed in yet? Sign in right here and run it again -
|
|
41
|
+
* a first-time user should never have to learn that `login` exists.
|
|
42
|
+
* Set VIRTUALMATTER_NO_AUTO_LOGIN=1 to get the old error instead.
|
|
43
|
+
*/
|
|
24
44
|
async function run(fn) {
|
|
25
45
|
try {
|
|
26
46
|
await fn();
|
|
27
47
|
}
|
|
28
48
|
catch (err) {
|
|
49
|
+
if (err instanceof NotLoggedInError && !process.env.VIRTUALMATTER_NO_AUTO_LOGIN) {
|
|
50
|
+
try {
|
|
51
|
+
await signIn();
|
|
52
|
+
await fn();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
catch (again) {
|
|
56
|
+
fail(again instanceof Error ? again.message : String(again));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
29
59
|
fail(err instanceof Error ? err.message : String(err));
|
|
30
60
|
}
|
|
31
61
|
}
|
|
@@ -35,20 +65,45 @@ async function clientForDir(dir) {
|
|
|
35
65
|
const session = await resolveSession(state.framing_id);
|
|
36
66
|
return { client: new FilesClient(sessionBaseUrl(session), state.framing_id), framingId: state.framing_id };
|
|
37
67
|
}
|
|
68
|
+
/** A target from the argument, else from the folder, else the only project. */
|
|
69
|
+
async function resolveTargetOrImplicit(target, dir) {
|
|
70
|
+
if (target)
|
|
71
|
+
return resolveTarget(target);
|
|
72
|
+
try {
|
|
73
|
+
return await resolveImplicitTarget(dir);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err instanceof NeedsChoiceError)
|
|
77
|
+
fail(err.message);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function slugFor(name) {
|
|
82
|
+
const slug = name
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.normalize("NFKD")
|
|
85
|
+
.replace(/[̀-ͯ]/g, "")
|
|
86
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
87
|
+
.replace(/^-+|-+$/g, "");
|
|
88
|
+
return slug || "world";
|
|
89
|
+
}
|
|
90
|
+
function reportPull(result, dest) {
|
|
91
|
+
console.log(`Pulled ${result.fileCount} files into ${dest}.`);
|
|
92
|
+
const files = result.agentFiles;
|
|
93
|
+
if (files && files.written.length > 0) {
|
|
94
|
+
console.log(`Wrote ${files.written.join(", ")} so Claude Code, Codex, and Cursor find their way around.`);
|
|
95
|
+
}
|
|
96
|
+
const rel = path.relative(process.cwd(), dest) || ".";
|
|
97
|
+
// A relative path is friendlier only while it stays short; a folder far
|
|
98
|
+
// from the cwd reads better as the absolute path it is.
|
|
99
|
+
const shown = rel.startsWith("..") ? dest : rel;
|
|
100
|
+
console.log(`Next: cd ${JSON.stringify(shown).slice(1, -1).includes(" ") ? `"${shown}"` : shown} && npx virtualmatter sync`);
|
|
101
|
+
console.log(` (or open the folder in your agent - the MCP server is registered in .mcp.json)`);
|
|
102
|
+
}
|
|
38
103
|
program
|
|
39
104
|
.command("login")
|
|
40
|
-
.description("Sign in with Virtual Matter using a device code")
|
|
41
|
-
.action(() => run(
|
|
42
|
-
const { auth, tokenEndpoint } = await startDeviceFlow();
|
|
43
|
-
const url = auth.verification_uri_complete ?? auth.verification_uri;
|
|
44
|
-
console.log(`Open ${url}`);
|
|
45
|
-
console.log(`and enter the code: ${auth.user_code}`);
|
|
46
|
-
openInBrowser(url);
|
|
47
|
-
const creds = await pollForToken(tokenEndpoint, auth);
|
|
48
|
-
saveCredentials(creds);
|
|
49
|
-
const me = await whoami();
|
|
50
|
-
console.log(`Signed in as ${me.username ?? me.email ?? me.id}.`);
|
|
51
|
-
}));
|
|
105
|
+
.description("Sign in with Virtual Matter using a device code (other commands do this automatically)")
|
|
106
|
+
.action(() => run(signIn));
|
|
52
107
|
program
|
|
53
108
|
.command("logout")
|
|
54
109
|
.description("Forget the stored credentials")
|
|
@@ -61,25 +116,106 @@ program
|
|
|
61
116
|
.description("Show who is signed in")
|
|
62
117
|
.action(() => run(async () => {
|
|
63
118
|
if (!loadCredentials())
|
|
64
|
-
|
|
119
|
+
throw new NotLoggedInError();
|
|
65
120
|
const me = await whoami();
|
|
66
121
|
console.log(JSON.stringify(me, null, 2));
|
|
67
122
|
}));
|
|
123
|
+
program
|
|
124
|
+
.command("embed <target>")
|
|
125
|
+
.description("Get iframe markup and required hosting setup for a shared world; no sign-in")
|
|
126
|
+
.action((target) => run(async () => console.log(JSON.stringify(await getEmbed(target), null, 2))));
|
|
127
|
+
program
|
|
128
|
+
.command("build <name>")
|
|
129
|
+
.description("Create a private world and send a build prompt to VM's agent (uses VM credits)")
|
|
130
|
+
.requiredOption("--prompt <text>", "What VM should build")
|
|
131
|
+
.requiredOption("--request-id <id>", "Stable identifier for this request; reuse on retries")
|
|
132
|
+
.option("--region <region>", "NA, EU, or AS", defaultRegion())
|
|
133
|
+
.action((name, opts) => run(async () => console.log(JSON.stringify(await createWebsiteBuild({ name, prompt: opts.prompt, request_id: opts.requestId, region: opts.region }), null, 2))));
|
|
134
|
+
program
|
|
135
|
+
.command("build-status <build-id>")
|
|
136
|
+
.description("Read a previously requested VM build's progress")
|
|
137
|
+
.action((id) => run(async () => console.log(JSON.stringify(await getWebsiteBuild(id), null, 2))));
|
|
138
|
+
program
|
|
139
|
+
.command("list")
|
|
140
|
+
.alias("ls")
|
|
141
|
+
.alias("projects")
|
|
142
|
+
.description("List your worlds (projects) with their editing framing ids and URLs")
|
|
143
|
+
.option("--json", "machine-readable output")
|
|
144
|
+
.action((opts) => run(async () => {
|
|
145
|
+
const projects = await listProjects();
|
|
146
|
+
if (opts.json) {
|
|
147
|
+
console.log(JSON.stringify(projects.map((p) => ({
|
|
148
|
+
name: p.name,
|
|
149
|
+
id: p.id,
|
|
150
|
+
region: p.region,
|
|
151
|
+
framing_id: p.make_framing_id,
|
|
152
|
+
edit_url: p.make_framing_id ? editUrl(p.make_framing_id, p.url_slug) : null,
|
|
153
|
+
play_url: p.play_framing_id ? playUrl(p.play_framing_id) : null,
|
|
154
|
+
updated_at: p.updated_at ?? null,
|
|
155
|
+
})), null, 2));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (projects.length === 0) {
|
|
159
|
+
console.log('No worlds yet. Create one: npx virtualmatter create "My world"');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
console.log(formatProjectList(projects));
|
|
163
|
+
console.log("");
|
|
164
|
+
console.log("Pull one: npx virtualmatter pull <framing id or URL>");
|
|
165
|
+
}));
|
|
166
|
+
program
|
|
167
|
+
.command("create")
|
|
168
|
+
.description("Create a new world and mirror its files into a local folder")
|
|
169
|
+
.argument("<name>", "the world's name")
|
|
170
|
+
.argument("[dir]", "destination folder (default: ./<name-as-slug>)")
|
|
171
|
+
.option("--region <region>", "NA, EU, or AS (default: nearest)")
|
|
172
|
+
.option("--description <text>", "one-line description")
|
|
173
|
+
.option("--track <track>", "engine track: stable or master", "stable")
|
|
174
|
+
.option("--no-pull", "create only; do not download the files")
|
|
175
|
+
.option("--open", "also open the new world in the native client")
|
|
176
|
+
.option("--json", "machine-readable output")
|
|
177
|
+
.action((name, dir, opts) => run(async () => {
|
|
178
|
+
const region = (opts.region ?? defaultRegion()).toUpperCase();
|
|
179
|
+
if (!["NA", "EU", "AS"].includes(region))
|
|
180
|
+
fail("--region must be NA, EU, or AS");
|
|
181
|
+
if (opts.track !== "stable" && opts.track !== "master")
|
|
182
|
+
fail("--track must be stable or master");
|
|
183
|
+
const project = await createProject({
|
|
184
|
+
name,
|
|
185
|
+
description: opts.description,
|
|
186
|
+
region,
|
|
187
|
+
engine_track: opts.track,
|
|
188
|
+
});
|
|
189
|
+
const framingId = makeFramingOf(project);
|
|
190
|
+
const url = editUrl(framingId, project.url_slug);
|
|
191
|
+
let pulled = null;
|
|
192
|
+
let dest = null;
|
|
193
|
+
if (opts.pull) {
|
|
194
|
+
dest = path.resolve(dir ?? slugFor(project.name));
|
|
195
|
+
pulled = await pullCommand(framingId, dest);
|
|
196
|
+
}
|
|
197
|
+
if (opts.json) {
|
|
198
|
+
console.log(JSON.stringify({ name: project.name, id: project.id, region: project.region, framing_id: framingId, edit_url: url, dir: dest, file_count: pulled?.fileCount ?? null }, null, 2));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
console.log(`Created "${project.name}" in ${project.region}.`);
|
|
202
|
+
console.log(`Edit in the browser: ${url}`);
|
|
203
|
+
if (pulled && dest)
|
|
204
|
+
reportPull(pulled, dest);
|
|
205
|
+
}
|
|
206
|
+
if (opts.open)
|
|
207
|
+
await openNative(framingId, url);
|
|
208
|
+
}));
|
|
68
209
|
program
|
|
69
210
|
.command("pull")
|
|
70
|
-
.description("Download a
|
|
71
|
-
.argument("
|
|
72
|
-
.argument("[dir]", "destination folder (default: ./<framing
|
|
211
|
+
.description("Download a world's file tree into a local folder")
|
|
212
|
+
.argument("[target]", "any Virtual Matter link (/edit, /play, /g, /p, /projects) or a framing id; omitted = your only world")
|
|
213
|
+
.argument("[dir]", "destination folder (default: ./<world name or framing id>)")
|
|
73
214
|
.action((target, dir) => run(async () => {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
fail(parsed.reason);
|
|
79
|
-
const dest = path.resolve(dir ?? parsed.framingId);
|
|
80
|
-
const result = await pullCommand(parsed.framingId, dest);
|
|
81
|
-
console.log(`Pulled ${result.fileCount} files into ${dest}.`);
|
|
82
|
-
console.log(`Next: cd ${path.relative(process.cwd(), dest) || "."} && virtualmatter sync`);
|
|
215
|
+
const resolved = await resolveTargetOrImplicit(target, path.resolve("."));
|
|
216
|
+
const dest = path.resolve(dir ?? (resolved.project ? slugFor(resolved.project.name) : resolved.framingId));
|
|
217
|
+
const result = await pullCommand(resolved.framingId, dest);
|
|
218
|
+
reportPull(result, dest);
|
|
83
219
|
}));
|
|
84
220
|
program
|
|
85
221
|
.command("sync")
|
|
@@ -116,43 +252,84 @@ program
|
|
|
116
252
|
const { client } = await clientForDir(path.resolve(dir));
|
|
117
253
|
console.log(JSON.stringify(await client.engineErrors(), null, 2));
|
|
118
254
|
}));
|
|
255
|
+
/** Parse an "x,y,z" flag into a numeric triple. */
|
|
256
|
+
function triple(value, flag) {
|
|
257
|
+
const parts = value.split(",").map((p) => Number(p.trim()));
|
|
258
|
+
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
|
|
259
|
+
fail(`${flag} takes three numbers, e.g. ${flag} 0,20,20`);
|
|
260
|
+
}
|
|
261
|
+
return [parts[0], parts[1], parts[2]];
|
|
262
|
+
}
|
|
119
263
|
program
|
|
120
264
|
.command("screenshot")
|
|
121
|
-
.description("Capture a PNG of the
|
|
265
|
+
.description("Capture a PNG of the world - a default overview, or aimed with --at/--rot or --target")
|
|
122
266
|
.argument("[dir]", "a synced folder", ".")
|
|
123
267
|
.option("-o, --out <file>", "output file", "screenshot.png")
|
|
268
|
+
.option("--at <x,y,z>", "camera position (default: 0,20,20)")
|
|
269
|
+
.option("--rot <yaw,pitch,roll>", "camera rotation in degrees, yaw first (default: 0,-45,0)")
|
|
270
|
+
.option("--target <id-or-name>", "frame this object instead of using a camera pose")
|
|
271
|
+
.option("--distance <meters>", "framing distance for --target (default: from the object's bounds)")
|
|
124
272
|
.action((dir, opts) => run(async () => {
|
|
125
273
|
const { client } = await clientForDir(path.resolve(dir));
|
|
126
|
-
const png = await client.screenshot(
|
|
274
|
+
const png = await client.screenshot({
|
|
275
|
+
at: opts.at ? triple(opts.at, "--at") : undefined,
|
|
276
|
+
rot: opts.rot ? triple(opts.rot, "--rot") : undefined,
|
|
277
|
+
target: opts.target,
|
|
278
|
+
distance: opts.distance === undefined ? undefined : Number(opts.distance),
|
|
279
|
+
});
|
|
127
280
|
fs.writeFileSync(opts.out, png);
|
|
128
281
|
console.log(`Wrote ${opts.out} (${png.length} bytes).`);
|
|
129
282
|
}));
|
|
283
|
+
/** Install the native client if needed, sign it in, and launch it into a world. */
|
|
284
|
+
async function openNative(framingId, url, force = false) {
|
|
285
|
+
const installed = await ensureClientInstalled({ framingId, force });
|
|
286
|
+
const seeded = seedNativeAuth(installed);
|
|
287
|
+
if (seeded.length > 0)
|
|
288
|
+
console.log("Signed the native client in with your account.");
|
|
289
|
+
launchClient(installed, url);
|
|
290
|
+
console.log(`Opening ${url} in the Virtual Matter client (${installed.dir}).`);
|
|
291
|
+
if (installed.platform === "linux") {
|
|
292
|
+
console.log("Linux: the client starts through run.sh with its bundled libraries. If no window appears, read the newest file in ~/.local/share/Atomontage/Atomontage Studio/UserData/Logs/ - \"Failed to initialize graphics adapter\" means this build has no renderer for your setup yet; the world still runs in the browser at the URL above, and every CLI command works without the client.");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
130
295
|
program
|
|
131
|
-
.command("
|
|
132
|
-
.
|
|
133
|
-
.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
296
|
+
.command("open")
|
|
297
|
+
.alias("client")
|
|
298
|
+
.description("Open a world in the native desktop client - downloads and signs it in on first use")
|
|
299
|
+
.argument("[target]", "any Virtual Matter link or framing id; omitted = this folder's world, or your only one")
|
|
300
|
+
.option("--install-only", "download and unpack the client without launching it")
|
|
301
|
+
.option("--print", "only print the download URL for this OS")
|
|
302
|
+
.option("--force", "re-download the client even if this build is already unpacked")
|
|
303
|
+
.action((target, opts) => run(async () => {
|
|
304
|
+
if (opts.print) {
|
|
305
|
+
const catalog = await fetchNativeClientCatalog(loadState(path.resolve("."))?.framing_id);
|
|
306
|
+
const platform = detectPlatform();
|
|
307
|
+
const mine = catalog.clients.filter((c) => c.platform.toLowerCase() === platform);
|
|
308
|
+
const rows = mine.length > 0 ? mine : catalog.clients;
|
|
309
|
+
if (mine.length === 0)
|
|
310
|
+
console.log(`No native client published for ${platform} yet. All available:`);
|
|
311
|
+
for (const c of rows) {
|
|
312
|
+
const u = c.url.startsWith("http") ? c.url : `${apiBase()}${c.url}`;
|
|
313
|
+
console.log(`${c.platform} (${c.kind}): ${u}`);
|
|
314
|
+
}
|
|
141
315
|
return;
|
|
142
316
|
}
|
|
143
|
-
|
|
144
|
-
|
|
317
|
+
if (opts.installOnly) {
|
|
318
|
+
const installed = await ensureClientInstalled({ force: opts.force });
|
|
319
|
+
const seeded = seedNativeAuth(installed);
|
|
320
|
+
console.log(`Installed the Virtual Matter client at ${installed.dir}${seeded.length ? " and signed it in." : "."}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const resolved = await resolveTargetOrImplicit(target, path.resolve("."));
|
|
324
|
+
await openNative(resolved.framingId, editUrl(resolved.framingId, resolved.project?.url_slug), opts.force);
|
|
145
325
|
}));
|
|
146
326
|
program
|
|
147
327
|
.command("mcp")
|
|
148
|
-
.description("Run the stdio MCP server for coding agents")
|
|
149
|
-
.argument("[dir]", "a synced folder whose state file names the
|
|
328
|
+
.description("Run the stdio MCP server for coding agents (works with or without a selected world)")
|
|
329
|
+
.argument("[dir]", "a synced folder whose state file names the world", ".")
|
|
150
330
|
.option("--framing <id>", "framing id (overrides the state file)")
|
|
151
331
|
.action((dir, opts) => run(async () => {
|
|
152
|
-
|
|
153
|
-
// Validate early so misconfiguration fails loudly at registration time.
|
|
154
|
-
resolveFramingId(abs, opts.framing);
|
|
155
|
-
await runMcpServer(abs, opts.framing);
|
|
332
|
+
await runMcpServer(path.resolve(dir), opts.framing);
|
|
156
333
|
}));
|
|
157
334
|
program.parseAsync(process.argv).catch((err) => {
|
|
158
335
|
fail(err instanceof Error ? err.message : String(err));
|
package/dist/mcp.js
CHANGED
|
@@ -1,44 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `virtualmatter mcp` - a stdio MCP server that gives a coding agent direct
|
|
3
|
-
* hands on
|
|
4
|
-
* engine errors, and
|
|
3
|
+
* hands on Virtual Matter: the account's projects, creating one, a live
|
|
4
|
+
* session's files, Lua execution, engine errors, screenshots, and the
|
|
5
|
+
* native client.
|
|
6
|
+
*
|
|
7
|
+
* The server starts without a world selected when the folder has no
|
|
8
|
+
* state file and no --framing was given; `list_projects`, `create_project`
|
|
9
|
+
* and `select_project` work in that state, and every world-bound tool says
|
|
10
|
+
* how to pick one.
|
|
5
11
|
*/
|
|
6
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
14
|
import { z } from "zod";
|
|
9
|
-
import { resolveSession, sessionBaseUrl } from "./api.js";
|
|
10
|
-
import {
|
|
15
|
+
import { createProject, createWebsiteBuild, getWebsiteBuild, getEmbed, defaultRegion, editUrl, listProjects, playUrl, resolveSession, sessionBaseUrl, } from "./api.js";
|
|
16
|
+
import { ensureClientInstalled, launchClient, seedNativeAuth } from "./client.js";
|
|
17
|
+
import { packageVersion } from "./config.js";
|
|
11
18
|
import { ConflictError, FilesClient } from "./files.js";
|
|
19
|
+
import { pullCommand } from "./pull.js";
|
|
20
|
+
import { makeFramingOf, resolveTarget } from "./resolve.js";
|
|
12
21
|
import { loadState } from "./state.js";
|
|
13
22
|
export function resolveFramingId(dir, framingFlag) {
|
|
14
23
|
if (framingFlag)
|
|
15
24
|
return framingFlag;
|
|
16
25
|
const state = loadState(dir);
|
|
17
|
-
|
|
18
|
-
return state.framing_id;
|
|
19
|
-
throw new Error(`No .virtualmatter.json in ${dir} and no --framing flag. Run \`virtualmatter pull\` there first, or pass --framing <id>.`);
|
|
26
|
+
return state ? state.framing_id : null;
|
|
20
27
|
}
|
|
28
|
+
const NO_WORLD = "No world is selected. Call select_project with a Virtual Matter URL or id, create_project to make a new one, or list_projects to see what exists.";
|
|
21
29
|
function makeContext(framingId) {
|
|
22
30
|
let session = null;
|
|
23
31
|
let client = null;
|
|
24
|
-
const
|
|
25
|
-
if (!session)
|
|
26
|
-
session = await resolveSession(framingId);
|
|
27
|
-
return session;
|
|
28
|
-
};
|
|
29
|
-
return {
|
|
32
|
+
const ctx = {
|
|
30
33
|
framingId,
|
|
31
|
-
|
|
34
|
+
project: null,
|
|
35
|
+
select(id, project) {
|
|
36
|
+
if (id !== ctx.framingId) {
|
|
37
|
+
session = null;
|
|
38
|
+
client = null;
|
|
39
|
+
}
|
|
40
|
+
ctx.framingId = id;
|
|
41
|
+
ctx.project = project ?? null;
|
|
42
|
+
},
|
|
43
|
+
requireFraming() {
|
|
44
|
+
if (!ctx.framingId)
|
|
45
|
+
throw new Error(NO_WORLD);
|
|
46
|
+
return ctx.framingId;
|
|
47
|
+
},
|
|
48
|
+
async getSession() {
|
|
49
|
+
if (!session)
|
|
50
|
+
session = await resolveSession(ctx.requireFraming());
|
|
51
|
+
return session;
|
|
52
|
+
},
|
|
32
53
|
async getClient() {
|
|
33
54
|
if (!client)
|
|
34
|
-
client = new FilesClient(sessionBaseUrl(await getSession()),
|
|
55
|
+
client = new FilesClient(sessionBaseUrl(await ctx.getSession()), ctx.requireFraming());
|
|
35
56
|
return client;
|
|
36
57
|
},
|
|
37
58
|
};
|
|
59
|
+
return ctx;
|
|
38
60
|
}
|
|
39
61
|
function textResult(text) {
|
|
40
62
|
return { content: [{ type: "text", text }] };
|
|
41
63
|
}
|
|
64
|
+
function projectSummary(p) {
|
|
65
|
+
return {
|
|
66
|
+
name: p.name,
|
|
67
|
+
id: p.id,
|
|
68
|
+
region: p.region,
|
|
69
|
+
framing_id: p.make_framing_id,
|
|
70
|
+
edit_url: p.make_framing_id ? editUrl(p.make_framing_id, p.url_slug) : null,
|
|
71
|
+
play_url: p.play_framing_id ? playUrl(p.play_framing_id) : null,
|
|
72
|
+
updated_at: p.updated_at ?? null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
42
75
|
/**
|
|
43
76
|
* write_file etag handling: look up the current etag from a listing, PUT with
|
|
44
77
|
* If-Match (or If-None-Match: * for a new file), and on 412 retry ONCE with a
|
|
@@ -62,9 +95,75 @@ export async function writeWithRetry(client, filePath, body) {
|
|
|
62
95
|
}
|
|
63
96
|
}
|
|
64
97
|
export async function runMcpServer(dir, framingFlag) {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
98
|
+
const ctx = makeContext(resolveFramingId(dir, framingFlag));
|
|
99
|
+
const server = new McpServer({ name: "virtualmatter", version: packageVersion() });
|
|
100
|
+
server.registerTool("get_embed", {
|
|
101
|
+
description: "Get canonical iframe markup and hosting instructions for a shared Virtual Matter world URL. No sign-in needed. Follow the instructions and verify the published page.",
|
|
102
|
+
inputSchema: { target: z.string() },
|
|
103
|
+
}, async ({ target }) => textResult(JSON.stringify(await getEmbed(target))));
|
|
104
|
+
server.registerTool("get_build_status", {
|
|
105
|
+
description: "Check a VM build by build_id. Do not create another project to check progress. Review the world and enable sharing before embedding.",
|
|
106
|
+
inputSchema: { build_id: z.string() },
|
|
107
|
+
}, async ({ build_id }) => textResult(JSON.stringify(await getWebsiteBuild(build_id))));
|
|
108
|
+
server.registerTool("list_projects", {
|
|
109
|
+
description: "List the signed-in maker's Virtual Matter projects (worlds): name, id, region, the editing framing id, and edit/play URLs. Call this to find a world to work in, then select_project.",
|
|
110
|
+
inputSchema: {},
|
|
111
|
+
}, async () => textResult(JSON.stringify((await listProjects()).map(projectSummary), null, 2)));
|
|
112
|
+
server.registerTool("create_project", {
|
|
113
|
+
description: "Create a new Virtual Matter world (project) for the signed-in maker and select it for this session. The world spins up live on Virtual Matter servers; its content is SDK Lua plus assets that hot-reload as you write them. Region defaults to the one nearest this machine.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
name: z.string().min(1).max(100).describe("Project name, e.g. \"Lava Arena\""),
|
|
116
|
+
prompt: z.string().min(1).max(16000).optional().describe("Optional build prompt to send to VM's agent, using VM credits. Requires request_id. Starts privately."),
|
|
117
|
+
request_id: z.string().min(8).max(100).optional().describe("Stable id for a prompted build; reuse unchanged on retries."),
|
|
118
|
+
description: z.string().max(1000).optional().describe("One-line description (optional)"),
|
|
119
|
+
region: z.enum(["NA", "EU", "AS"]).optional().describe("Hosting region (default: nearest)"),
|
|
120
|
+
pull_to: z
|
|
121
|
+
.string()
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Local folder to mirror the new world's files into (optional; sync works from there)"),
|
|
124
|
+
},
|
|
125
|
+
}, async ({ name, description, region, pull_to, prompt, request_id }) => {
|
|
126
|
+
if (prompt) {
|
|
127
|
+
if (!request_id)
|
|
128
|
+
throw new Error("A stable request_id is required when sending a build prompt.");
|
|
129
|
+
if (pull_to || description)
|
|
130
|
+
throw new Error("Prompted builds run in VM. Omit pull_to/description and use select_project afterwards if local files are needed.");
|
|
131
|
+
const build = await createWebsiteBuild({ name, prompt, request_id, region: region ?? defaultRegion() });
|
|
132
|
+
if (build.framing_id)
|
|
133
|
+
ctx.select(build.framing_id);
|
|
134
|
+
return textResult(JSON.stringify(build));
|
|
135
|
+
}
|
|
136
|
+
const project = await createProject({ name, description, region: region ?? defaultRegion() });
|
|
137
|
+
const framingId = makeFramingOf(project);
|
|
138
|
+
ctx.select(framingId, project);
|
|
139
|
+
let pulled = null;
|
|
140
|
+
if (pull_to) {
|
|
141
|
+
const result = await pullCommand(framingId, pull_to);
|
|
142
|
+
pulled = { dir: result.dir, file_count: result.fileCount, agent_files: result.agentFiles };
|
|
143
|
+
}
|
|
144
|
+
return textResult(JSON.stringify({ ...projectSummary(project), selected: true, pulled }, null, 2));
|
|
145
|
+
});
|
|
146
|
+
server.registerTool("select_project", {
|
|
147
|
+
description: "Select the world this session works on. Accepts any Virtual Matter link (/edit, /play, /g, /p, /projects, with or without a readable slug) or a bare framing id. Optionally mirrors its files into a local folder.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
target: z.string().describe("A Virtual Matter URL or framing id"),
|
|
150
|
+
pull_to: z.string().optional().describe("Local folder to mirror the world's files into (optional)"),
|
|
151
|
+
},
|
|
152
|
+
}, async ({ target, pull_to }) => {
|
|
153
|
+
const resolved = await resolveTarget(target);
|
|
154
|
+
ctx.select(resolved.framingId, resolved.project);
|
|
155
|
+
let pulled = null;
|
|
156
|
+
if (pull_to) {
|
|
157
|
+
const result = await pullCommand(resolved.framingId, pull_to);
|
|
158
|
+
pulled = { dir: result.dir, file_count: result.fileCount, agent_files: result.agentFiles };
|
|
159
|
+
}
|
|
160
|
+
return textResult(JSON.stringify({
|
|
161
|
+
framing_id: resolved.framingId,
|
|
162
|
+
edit_url: editUrl(resolved.framingId, resolved.project?.url_slug),
|
|
163
|
+
project: resolved.project ? projectSummary(resolved.project) : null,
|
|
164
|
+
pulled,
|
|
165
|
+
}, null, 2));
|
|
166
|
+
});
|
|
68
167
|
server.registerTool("list_files", {
|
|
69
168
|
description: "List every file in the live Virtual Matter session's Montage tree. Returns path, size, mtime_ms, and etag per file. Call this before reading or writing to learn what exists. Lua scripts under this tree hot-reload in the engine when written.",
|
|
70
169
|
inputSchema: {},
|
|
@@ -113,26 +212,58 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
113
212
|
return textResult(JSON.stringify(await client.engineErrors(), null, 2));
|
|
114
213
|
});
|
|
115
214
|
server.registerTool("capture_screenshot", {
|
|
116
|
-
description: "Capture a PNG screenshot of the
|
|
117
|
-
inputSchema: {
|
|
118
|
-
|
|
215
|
+
description: "Capture a PNG screenshot of the world and return it as an image - how you SEE what you built. With no arguments it shoots a default overview of the world origin. Pass target to frame one object by name or id (easiest and usually what you want), or a full at + rot camera pose. Use it to visually verify a change instead of assuming it worked.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
target: z
|
|
218
|
+
.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe("Object name or id to frame; the engine picks the distance"),
|
|
221
|
+
at: z
|
|
222
|
+
.array(z.number())
|
|
223
|
+
.length(3)
|
|
224
|
+
.optional()
|
|
225
|
+
.describe("Camera position [x, y, z]; Y is up and -Z is forward"),
|
|
226
|
+
rot: z
|
|
227
|
+
.array(z.number())
|
|
228
|
+
.length(3)
|
|
229
|
+
.optional()
|
|
230
|
+
.describe("Camera rotation in degrees as [yaw, pitch, roll] - that order. Yaw 0 faces -Z; pitch -90 looks straight down, 0 is the horizon. Default [0, -45, 0] looks down at the origin."),
|
|
231
|
+
},
|
|
232
|
+
}, async ({ target, at, rot }) => {
|
|
119
233
|
const client = await ctx.getClient();
|
|
120
|
-
const png = await client.screenshot(
|
|
234
|
+
const png = await client.screenshot({
|
|
235
|
+
target,
|
|
236
|
+
at: at,
|
|
237
|
+
rot: rot,
|
|
238
|
+
});
|
|
121
239
|
return {
|
|
122
240
|
content: [
|
|
123
241
|
{ type: "image", data: png.toString("base64"), mimeType: "image/png" },
|
|
124
242
|
],
|
|
125
243
|
};
|
|
126
244
|
});
|
|
245
|
+
server.registerTool("open_native_client", {
|
|
246
|
+
description: "Open the selected world on this machine in the Virtual Matter native desktop client (higher performance than the browser). Downloads and unpacks the client on first use, signs it in with the CLI's account, and launches it into the world. Returns where the client lives and the URL it opened.",
|
|
247
|
+
inputSchema: {},
|
|
248
|
+
}, async () => {
|
|
249
|
+
const framingId = ctx.requireFraming();
|
|
250
|
+
const installed = await ensureClientInstalled({ framingId, log: () => undefined });
|
|
251
|
+
const signedIn = seedNativeAuth(installed);
|
|
252
|
+
const url = editUrl(framingId);
|
|
253
|
+
launchClient(installed, url);
|
|
254
|
+
return textResult(JSON.stringify({ launched: true, url, client_dir: installed.dir, signed_in_via_cli: signedIn.length > 0 }, null, 2));
|
|
255
|
+
});
|
|
127
256
|
server.registerTool("world_info", {
|
|
128
|
-
description: "Return the framing id plus
|
|
257
|
+
description: "Return the selected world's framing id plus its URLs: the editor and play links a human can open, and the session API base this server talks to.",
|
|
129
258
|
inputSchema: {},
|
|
130
259
|
}, async () => {
|
|
260
|
+
const framingId = ctx.requireFraming();
|
|
131
261
|
const session = await ctx.getSession();
|
|
132
262
|
return textResult(JSON.stringify({
|
|
133
|
-
framing_id:
|
|
134
|
-
|
|
135
|
-
|
|
263
|
+
framing_id: framingId,
|
|
264
|
+
project: ctx.project ? projectSummary(ctx.project) : null,
|
|
265
|
+
edit_url: editUrl(framingId, ctx.project?.url_slug),
|
|
266
|
+
play_url: playUrl(framingId),
|
|
136
267
|
session_api_base: sessionBaseUrl(session),
|
|
137
268
|
voxel_host: session.voxel_host,
|
|
138
269
|
session_id: session.session_id,
|