virtualmatter 0.1.0 → 0.2.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 +48 -33
- package/dist/agentfiles.js +72 -0
- package/dist/api.js +93 -0
- package/dist/auth.js +10 -4
- package/dist/client.js +317 -0
- package/dist/config.js +23 -0
- package/dist/ignore.js +3 -1
- package/dist/index.js +189 -50
- package/dist/mcp.js +114 -22
- package/dist/pull.js +48 -41
- package/dist/resolve.js +65 -0
- package/dist/urls.js +80 -25
- package/package.json +4 -4
package/dist/config.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
/** Platform API base. Overridable so dev/staging environments work. */
|
|
4
6
|
export function apiBase() {
|
|
5
7
|
const raw = process.env.VIRTUALMATTER_API_BASE ?? "https://make.virtualmatter.ai";
|
|
@@ -18,5 +20,26 @@ export function configDir() {
|
|
|
18
20
|
export function credentialsPath() {
|
|
19
21
|
return path.join(configDir(), "credentials.json");
|
|
20
22
|
}
|
|
23
|
+
/** Where downloaded native client builds are unpacked: <configDir>/client/<build>. */
|
|
24
|
+
export function clientInstallRoot() {
|
|
25
|
+
return process.env.VIRTUALMATTER_CLIENT_DIR ?? path.join(configDir(), "client");
|
|
26
|
+
}
|
|
21
27
|
/** Name of the per-directory sync state file. */
|
|
22
28
|
export const STATE_FILE = ".virtualmatter.json";
|
|
29
|
+
/** The package version, read from package.json so it can never drift from npm. */
|
|
30
|
+
export function packageVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
for (const candidate of [path.join(here, "..", "package.json"), path.join(here, "..", "..", "package.json")]) {
|
|
34
|
+
if (fs.existsSync(candidate)) {
|
|
35
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
36
|
+
if (pkg.name === "virtualmatter" && typeof pkg.version === "string")
|
|
37
|
+
return pkg.version;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* fall through */
|
|
43
|
+
}
|
|
44
|
+
return "0.0.0";
|
|
45
|
+
}
|
package/dist/ignore.js
CHANGED
|
@@ -15,7 +15,9 @@ export function isIgnoredPath(relPath) {
|
|
|
15
15
|
return true;
|
|
16
16
|
if (norm === STATE_FILE)
|
|
17
17
|
return true;
|
|
18
|
-
|
|
18
|
+
// Harness files the CLI drops into a pulled folder; they belong to the
|
|
19
|
+
// maker's machine, not the world.
|
|
20
|
+
if (norm === "AGENTS.md" || norm === "CLAUDE.md")
|
|
19
21
|
return true;
|
|
20
22
|
if (norm.endsWith(".remote-conflict"))
|
|
21
23
|
return true;
|
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, 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,42 @@ 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
|
+
console.log(`Next: cd ${rel} && npx virtualmatter sync`);
|
|
98
|
+
console.log(` (or open the folder in your agent - the MCP server is registered in .mcp.json)`);
|
|
99
|
+
}
|
|
38
100
|
program
|
|
39
101
|
.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
|
-
}));
|
|
102
|
+
.description("Sign in with Virtual Matter using a device code (other commands do this automatically)")
|
|
103
|
+
.action(() => run(signIn));
|
|
52
104
|
program
|
|
53
105
|
.command("logout")
|
|
54
106
|
.description("Forget the stored credentials")
|
|
@@ -61,25 +113,91 @@ program
|
|
|
61
113
|
.description("Show who is signed in")
|
|
62
114
|
.action(() => run(async () => {
|
|
63
115
|
if (!loadCredentials())
|
|
64
|
-
|
|
116
|
+
throw new NotLoggedInError();
|
|
65
117
|
const me = await whoami();
|
|
66
118
|
console.log(JSON.stringify(me, null, 2));
|
|
67
119
|
}));
|
|
120
|
+
program
|
|
121
|
+
.command("list")
|
|
122
|
+
.alias("ls")
|
|
123
|
+
.alias("projects")
|
|
124
|
+
.description("List your worlds (projects) with their editing framing ids and URLs")
|
|
125
|
+
.option("--json", "machine-readable output")
|
|
126
|
+
.action((opts) => run(async () => {
|
|
127
|
+
const projects = await listProjects();
|
|
128
|
+
if (opts.json) {
|
|
129
|
+
console.log(JSON.stringify(projects.map((p) => ({
|
|
130
|
+
name: p.name,
|
|
131
|
+
id: p.id,
|
|
132
|
+
region: p.region,
|
|
133
|
+
framing_id: p.make_framing_id,
|
|
134
|
+
edit_url: p.make_framing_id ? editUrl(p.make_framing_id, p.url_slug) : null,
|
|
135
|
+
play_url: p.play_framing_id ? playUrl(p.play_framing_id) : null,
|
|
136
|
+
updated_at: p.updated_at ?? null,
|
|
137
|
+
})), null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (projects.length === 0) {
|
|
141
|
+
console.log('No worlds yet. Create one: npx virtualmatter create "My world"');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.log(formatProjectList(projects));
|
|
145
|
+
console.log("");
|
|
146
|
+
console.log("Pull one: npx virtualmatter pull <framing id or URL>");
|
|
147
|
+
}));
|
|
148
|
+
program
|
|
149
|
+
.command("create")
|
|
150
|
+
.description("Create a new world and mirror its files into a local folder")
|
|
151
|
+
.argument("<name>", "the world's name")
|
|
152
|
+
.argument("[dir]", "destination folder (default: ./<name-as-slug>)")
|
|
153
|
+
.option("--region <region>", "NA, EU, or AS (default: nearest)")
|
|
154
|
+
.option("--description <text>", "one-line description")
|
|
155
|
+
.option("--track <track>", "engine track: stable or master", "stable")
|
|
156
|
+
.option("--no-pull", "create only; do not download the files")
|
|
157
|
+
.option("--open", "also open the new world in the native client")
|
|
158
|
+
.option("--json", "machine-readable output")
|
|
159
|
+
.action((name, dir, opts) => run(async () => {
|
|
160
|
+
const region = (opts.region ?? defaultRegion()).toUpperCase();
|
|
161
|
+
if (!["NA", "EU", "AS"].includes(region))
|
|
162
|
+
fail("--region must be NA, EU, or AS");
|
|
163
|
+
if (opts.track !== "stable" && opts.track !== "master")
|
|
164
|
+
fail("--track must be stable or master");
|
|
165
|
+
const project = await createProject({
|
|
166
|
+
name,
|
|
167
|
+
description: opts.description,
|
|
168
|
+
region,
|
|
169
|
+
engine_track: opts.track,
|
|
170
|
+
});
|
|
171
|
+
const framingId = makeFramingOf(project);
|
|
172
|
+
const url = editUrl(framingId, project.url_slug);
|
|
173
|
+
let pulled = null;
|
|
174
|
+
let dest = null;
|
|
175
|
+
if (opts.pull) {
|
|
176
|
+
dest = path.resolve(dir ?? slugFor(project.name));
|
|
177
|
+
pulled = await pullCommand(framingId, dest);
|
|
178
|
+
}
|
|
179
|
+
if (opts.json) {
|
|
180
|
+
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));
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
console.log(`Created "${project.name}" in ${project.region}.`);
|
|
184
|
+
console.log(`Edit in the browser: ${url}`);
|
|
185
|
+
if (pulled && dest)
|
|
186
|
+
reportPull(pulled, dest);
|
|
187
|
+
}
|
|
188
|
+
if (opts.open)
|
|
189
|
+
await openNative(framingId, url);
|
|
190
|
+
}));
|
|
68
191
|
program
|
|
69
192
|
.command("pull")
|
|
70
|
-
.description("Download a
|
|
71
|
-
.argument("
|
|
72
|
-
.argument("[dir]", "destination folder (default: ./<framing
|
|
193
|
+
.description("Download a world's file tree into a local folder")
|
|
194
|
+
.argument("[target]", "any Virtual Matter link (/edit, /play, /g, /p, /projects) or a framing id; omitted = your only world")
|
|
195
|
+
.argument("[dir]", "destination folder (default: ./<world name or framing id>)")
|
|
73
196
|
.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`);
|
|
197
|
+
const resolved = await resolveTargetOrImplicit(target, path.resolve("."));
|
|
198
|
+
const dest = path.resolve(dir ?? (resolved.project ? slugFor(resolved.project.name) : resolved.framingId));
|
|
199
|
+
const result = await pullCommand(resolved.framingId, dest);
|
|
200
|
+
reportPull(result, dest);
|
|
83
201
|
}));
|
|
84
202
|
program
|
|
85
203
|
.command("sync")
|
|
@@ -127,32 +245,53 @@ program
|
|
|
127
245
|
fs.writeFileSync(opts.out, png);
|
|
128
246
|
console.log(`Wrote ${opts.out} (${png.length} bytes).`);
|
|
129
247
|
}));
|
|
248
|
+
/** Install the native client if needed, sign it in, and launch it into a world. */
|
|
249
|
+
async function openNative(framingId, url, force = false) {
|
|
250
|
+
const installed = await ensureClientInstalled({ framingId, force });
|
|
251
|
+
const seeded = seedNativeAuth(installed);
|
|
252
|
+
if (seeded.length > 0)
|
|
253
|
+
console.log("Signed the native client in with your account.");
|
|
254
|
+
launchClient(installed, url);
|
|
255
|
+
console.log(`Opening ${url} in the Virtual Matter client (${installed.dir}).`);
|
|
256
|
+
}
|
|
130
257
|
program
|
|
131
|
-
.command("
|
|
132
|
-
.
|
|
133
|
-
.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
258
|
+
.command("open")
|
|
259
|
+
.alias("client")
|
|
260
|
+
.description("Open a world in the native desktop client - downloads and signs it in on first use")
|
|
261
|
+
.argument("[target]", "any Virtual Matter link or framing id; omitted = this folder's world, or your only one")
|
|
262
|
+
.option("--install-only", "download and unpack the client without launching it")
|
|
263
|
+
.option("--print", "only print the download URL for this OS")
|
|
264
|
+
.option("--force", "re-download the client even if this build is already unpacked")
|
|
265
|
+
.action((target, opts) => run(async () => {
|
|
266
|
+
if (opts.print) {
|
|
267
|
+
const catalog = await fetchNativeClientCatalog(loadState(path.resolve("."))?.framing_id);
|
|
268
|
+
const platform = detectPlatform();
|
|
269
|
+
const mine = catalog.clients.filter((c) => c.platform.toLowerCase() === platform);
|
|
270
|
+
const rows = mine.length > 0 ? mine : catalog.clients;
|
|
271
|
+
if (mine.length === 0)
|
|
272
|
+
console.log(`No native client published for ${platform} yet. All available:`);
|
|
273
|
+
for (const c of rows) {
|
|
274
|
+
const u = c.url.startsWith("http") ? c.url : `${apiBase()}${c.url}`;
|
|
275
|
+
console.log(`${c.platform} (${c.kind}): ${u}`);
|
|
276
|
+
}
|
|
141
277
|
return;
|
|
142
278
|
}
|
|
143
|
-
|
|
144
|
-
|
|
279
|
+
if (opts.installOnly) {
|
|
280
|
+
const installed = await ensureClientInstalled({ force: opts.force });
|
|
281
|
+
const seeded = seedNativeAuth(installed);
|
|
282
|
+
console.log(`Installed the Virtual Matter client at ${installed.dir}${seeded.length ? " and signed it in." : "."}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const resolved = await resolveTargetOrImplicit(target, path.resolve("."));
|
|
286
|
+
await openNative(resolved.framingId, editUrl(resolved.framingId, resolved.project?.url_slug), opts.force);
|
|
145
287
|
}));
|
|
146
288
|
program
|
|
147
289
|
.command("mcp")
|
|
148
|
-
.description("Run the stdio MCP server for coding agents")
|
|
149
|
-
.argument("[dir]", "a synced folder whose state file names the
|
|
290
|
+
.description("Run the stdio MCP server for coding agents (works with or without a selected world)")
|
|
291
|
+
.argument("[dir]", "a synced folder whose state file names the world", ".")
|
|
150
292
|
.option("--framing <id>", "framing id (overrides the state file)")
|
|
151
293
|
.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);
|
|
294
|
+
await runMcpServer(path.resolve(dir), opts.framing);
|
|
156
295
|
}));
|
|
157
296
|
program.parseAsync(process.argv).catch((err) => {
|
|
158
297
|
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, 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,55 @@ 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("list_projects", {
|
|
101
|
+
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.",
|
|
102
|
+
inputSchema: {},
|
|
103
|
+
}, async () => textResult(JSON.stringify((await listProjects()).map(projectSummary), null, 2)));
|
|
104
|
+
server.registerTool("create_project", {
|
|
105
|
+
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.",
|
|
106
|
+
inputSchema: {
|
|
107
|
+
name: z.string().min(1).max(100).describe("Project name, e.g. \"Lava Arena\""),
|
|
108
|
+
description: z.string().max(1000).optional().describe("One-line description (optional)"),
|
|
109
|
+
region: z.enum(["NA", "EU", "AS"]).optional().describe("Hosting region (default: nearest)"),
|
|
110
|
+
pull_to: z
|
|
111
|
+
.string()
|
|
112
|
+
.optional()
|
|
113
|
+
.describe("Local folder to mirror the new world's files into (optional; sync works from there)"),
|
|
114
|
+
},
|
|
115
|
+
}, async ({ name, description, region, pull_to }) => {
|
|
116
|
+
const project = await createProject({ name, description, region: region ?? defaultRegion() });
|
|
117
|
+
const framingId = makeFramingOf(project);
|
|
118
|
+
ctx.select(framingId, project);
|
|
119
|
+
let pulled = null;
|
|
120
|
+
if (pull_to) {
|
|
121
|
+
const result = await pullCommand(framingId, pull_to);
|
|
122
|
+
pulled = { dir: result.dir, file_count: result.fileCount, agent_files: result.agentFiles };
|
|
123
|
+
}
|
|
124
|
+
return textResult(JSON.stringify({ ...projectSummary(project), selected: true, pulled }, null, 2));
|
|
125
|
+
});
|
|
126
|
+
server.registerTool("select_project", {
|
|
127
|
+
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.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
target: z.string().describe("A Virtual Matter URL or framing id"),
|
|
130
|
+
pull_to: z.string().optional().describe("Local folder to mirror the world's files into (optional)"),
|
|
131
|
+
},
|
|
132
|
+
}, async ({ target, pull_to }) => {
|
|
133
|
+
const resolved = await resolveTarget(target);
|
|
134
|
+
ctx.select(resolved.framingId, resolved.project);
|
|
135
|
+
let pulled = null;
|
|
136
|
+
if (pull_to) {
|
|
137
|
+
const result = await pullCommand(resolved.framingId, pull_to);
|
|
138
|
+
pulled = { dir: result.dir, file_count: result.fileCount, agent_files: result.agentFiles };
|
|
139
|
+
}
|
|
140
|
+
return textResult(JSON.stringify({
|
|
141
|
+
framing_id: resolved.framingId,
|
|
142
|
+
edit_url: editUrl(resolved.framingId, resolved.project?.url_slug),
|
|
143
|
+
project: resolved.project ? projectSummary(resolved.project) : null,
|
|
144
|
+
pulled,
|
|
145
|
+
}, null, 2));
|
|
146
|
+
});
|
|
68
147
|
server.registerTool("list_files", {
|
|
69
148
|
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
149
|
inputSchema: {},
|
|
@@ -124,15 +203,28 @@ export async function runMcpServer(dir, framingFlag) {
|
|
|
124
203
|
],
|
|
125
204
|
};
|
|
126
205
|
});
|
|
206
|
+
server.registerTool("open_native_client", {
|
|
207
|
+
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.",
|
|
208
|
+
inputSchema: {},
|
|
209
|
+
}, async () => {
|
|
210
|
+
const framingId = ctx.requireFraming();
|
|
211
|
+
const installed = await ensureClientInstalled({ framingId, log: () => undefined });
|
|
212
|
+
const signedIn = seedNativeAuth(installed);
|
|
213
|
+
const url = editUrl(framingId);
|
|
214
|
+
launchClient(installed, url);
|
|
215
|
+
return textResult(JSON.stringify({ launched: true, url, client_dir: installed.dir, signed_in_via_cli: signedIn.length > 0 }, null, 2));
|
|
216
|
+
});
|
|
127
217
|
server.registerTool("world_info", {
|
|
128
|
-
description: "Return the framing id plus
|
|
218
|
+
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
219
|
inputSchema: {},
|
|
130
220
|
}, async () => {
|
|
221
|
+
const framingId = ctx.requireFraming();
|
|
131
222
|
const session = await ctx.getSession();
|
|
132
223
|
return textResult(JSON.stringify({
|
|
133
|
-
framing_id:
|
|
134
|
-
|
|
135
|
-
|
|
224
|
+
framing_id: framingId,
|
|
225
|
+
project: ctx.project ? projectSummary(ctx.project) : null,
|
|
226
|
+
edit_url: editUrl(framingId, ctx.project?.url_slug),
|
|
227
|
+
play_url: playUrl(framingId),
|
|
136
228
|
session_api_base: sessionBaseUrl(session),
|
|
137
229
|
voxel_host: session.voxel_host,
|
|
138
230
|
session_id: session.session_id,
|
package/dist/pull.js
CHANGED
|
@@ -2,53 +2,61 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { resolveSession, sessionBaseUrl } from "./api.js";
|
|
5
|
-
import {
|
|
5
|
+
import { fetchAgentsMd, writeAgentFiles } from "./agentfiles.js";
|
|
6
6
|
import { FilesClient } from "./files.js";
|
|
7
7
|
import { isIgnoredPath } from "./ignore.js";
|
|
8
8
|
import { saveState } from "./state.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
9
|
+
export { AGENTS_MD_STUB, fetchAgentsMd } from "./agentfiles.js";
|
|
10
|
+
/** Download workers per pull. The tree is ~1000 small files; sequential
|
|
11
|
+
* GETs left the command silent for minutes. Eight in flight keeps a
|
|
12
|
+
* single origin comfortable while cutting wall clock roughly 6-8x. */
|
|
13
|
+
const PULL_CONCURRENCY = 8;
|
|
14
|
+
/** One mutable status line on a TTY (`\r`-overwritten); on a plain
|
|
15
|
+
* pipe, a heartbeat line every 100 files instead of 1000 spam lines. */
|
|
16
|
+
function makeProgress(total) {
|
|
17
|
+
const tty = process.stderr.isTTY === true;
|
|
18
|
+
let count = 0;
|
|
19
|
+
return {
|
|
20
|
+
tick(current) {
|
|
21
|
+
count++;
|
|
22
|
+
if (tty) {
|
|
23
|
+
const label = current.length > 60 ? `...${current.slice(-57)}` : current;
|
|
24
|
+
process.stderr.write(`\r\x1b[2K[${count}/${total}] ${label}`);
|
|
25
|
+
}
|
|
26
|
+
else if (count % 100 === 0 || count === total) {
|
|
27
|
+
process.stderr.write(`[${count}/${total}] files pulled\n`);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
done() {
|
|
31
|
+
if (tty)
|
|
32
|
+
process.stderr.write("\r\x1b[2K");
|
|
33
|
+
},
|
|
34
|
+
};
|
|
34
35
|
}
|
|
35
36
|
export async function pullTree(client, dir, framingId) {
|
|
36
37
|
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
-
const remote = await client.list();
|
|
38
|
+
const remote = (await client.list()).filter((f) => !isIgnoredPath(f.path));
|
|
38
39
|
const state = { framing_id: framingId, etags: {} };
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
const progress = makeProgress(remote.length);
|
|
41
|
+
let next = 0;
|
|
42
|
+
const worker = async () => {
|
|
43
|
+
for (;;) {
|
|
44
|
+
const index = next++;
|
|
45
|
+
if (index >= remote.length)
|
|
46
|
+
return;
|
|
47
|
+
const file = remote[index];
|
|
48
|
+
const { bytes, etag } = await client.get(file.path);
|
|
49
|
+
const absPath = path.join(dir, file.path);
|
|
50
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
51
|
+
fs.writeFileSync(absPath, bytes);
|
|
52
|
+
state.etags[file.path] = etag ?? file.etag;
|
|
53
|
+
progress.tick(file.path);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(PULL_CONCURRENCY, remote.length) }, () => worker()));
|
|
57
|
+
progress.done();
|
|
50
58
|
saveState(dir, state);
|
|
51
|
-
return { dir, framingId, fileCount:
|
|
59
|
+
return { dir, framingId, fileCount: remote.length };
|
|
52
60
|
}
|
|
53
61
|
export async function pullCommand(framingId, dir) {
|
|
54
62
|
console.log(`Resolving session for framing ${framingId} (this can cold-start one) ...`);
|
|
@@ -56,7 +64,6 @@ export async function pullCommand(framingId, dir) {
|
|
|
56
64
|
const client = new FilesClient(sessionBaseUrl(session), framingId);
|
|
57
65
|
console.log(`Session ready at ${sessionBaseUrl(session)}. Downloading files ...`);
|
|
58
66
|
const result = await pullTree(client, dir, framingId);
|
|
59
|
-
|
|
60
|
-
fs.writeFileSync(path.join(dir, "AGENTS.md"), agentsMd);
|
|
67
|
+
result.agentFiles = writeAgentFiles(dir, await fetchAgentsMd());
|
|
61
68
|
return result;
|
|
62
69
|
}
|