virtualmatter 0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atomontage Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # virtualmatter
2
+
3
+ The command line for building with Virtual Matter - and the MCP server that
4
+ gives coding agents direct hands on a live session: montage files, Lua
5
+ execution, engine errors, and screenshots.
6
+
7
+ No install needed:
8
+
9
+ ```bash
10
+ npx virtualmatter --help
11
+ ```
12
+
13
+ (`vm` works as a shorthand alias when installed.)
14
+
15
+ Requires Node 20 or newer. No native modules.
16
+
17
+ ## Quickstart
18
+
19
+ ```bash
20
+ # 1. Sign in (device code - approve it in your browser)
21
+ npx virtualmatter login
22
+
23
+ # 2. Pull a world you can edit. Paste the /edit link from your browser.
24
+ npx virtualmatter pull https://make.virtualmatter.ai/edit/<framing-id> my-world
25
+ cd my-world
26
+
27
+ # 3. Start the hot loop: local edits push instantly, remote edits pull back.
28
+ npx virtualmatter sync
29
+ ```
30
+
31
+ While `sync` runs, saving a Lua script in your editor deploys it - scripts
32
+ hot-reload in the engine. If someone edits the same file in the live session,
33
+ your copy stays put and the remote version lands next to it as
34
+ `<name>.remote-conflict` for you to merge.
35
+
36
+ Poke at the running engine from another terminal:
37
+
38
+ ```bash
39
+ npx virtualmatter run-lua --code "return Server.GetInfo()"
40
+ npx virtualmatter errors
41
+ npx virtualmatter screenshot -o shot.png
42
+ ```
43
+
44
+ ## Hook up a coding agent (MCP)
45
+
46
+ Register the MCP server with Claude Code in one line, from a pulled folder:
47
+
48
+ ```bash
49
+ claude mcp add virtualmatter -- npx -y virtualmatter mcp
50
+ ```
51
+
52
+ Or point it at a framing directly, no pulled folder needed:
53
+
54
+ ```bash
55
+ claude mcp add virtualmatter -- npx -y virtualmatter mcp --framing <framing-id>
56
+ ```
57
+
58
+ The server exposes `list_files`, `read_file`, `write_file`, `run_lua`,
59
+ `get_engine_errors`, `capture_screenshot`, and `world_info`. Writes handle
60
+ etag concurrency internally, and written Lua hot-reloads in the engine - so
61
+ for an agent, writing a script is deploying it.
62
+
63
+ ## Commands
64
+
65
+ | Command | What it does |
66
+ | --- | --- |
67
+ | `login` / `logout` / `whoami` | Device-code sign-in against the Virtual Matter identity server; tokens live in `~/.config/virtualmatter/credentials.json` (mode 0600) and refresh automatically. |
68
+ | `pull <url-or-framing-id> [dir]` | Resolve (cold-starting if needed) the framing's session and download its file tree, plus an `AGENTS.md` briefing for agents. |
69
+ | `sync [dir]` | Watch + two-way sync with the live session. Ctrl-C to stop. |
70
+ | `run-lua [dir] --code "<lua>" [--target server\|client]` | Execute Lua in the running engine. |
71
+ | `errors [dir]` | Recent engine errors. |
72
+ | `screenshot [dir] [-o out.png]` | Capture the engine's current view. |
73
+ | `client` | Print the native client download link for your OS. |
74
+ | `mcp [dir] [--framing <id>]` | Run the stdio MCP server. |
75
+
76
+ `pull` understands `/edit/<id>`, `/play/<id>`, and `/m/<id>` links from any
77
+ Virtual Matter host, or a bare framing id. Project-level links (`/g/...`,
78
+ `/play/p/...`) point at a whole project - open the project and copy the
79
+ `/edit` link for the framing you want.
80
+
81
+ ## Good to know
82
+
83
+ - **Device sign-in is rolling out.** If `login` reports that device sign-in
84
+ is not enabled yet for this environment, the server-side toggle has not
85
+ reached your environment - it is coming.
86
+ - `VIRTUALMATTER_API_BASE` overrides the platform API base (default
87
+ `https://make.virtualmatter.ai`) for dev/staging environments.
88
+ - Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, and its own
89
+ bookkeeping (`.virtualmatter.json`).
90
+ - `pull` may cold-start a session for the framing; the first one can take a
91
+ minute.
package/dist/api.js ADDED
@@ -0,0 +1,60 @@
1
+ /** Platform API client: authorized fetch, whoami, session resolution, native clients. */
2
+ import { getAccessToken } from "./auth.js";
3
+ import { apiBase } from "./config.js";
4
+ export async function authorizedFetch(url, init = {}, fetchFn = fetch) {
5
+ const token = await getAccessToken(fetchFn);
6
+ const headers = new Headers(init.headers);
7
+ headers.set("Authorization", `Bearer ${token}`);
8
+ return fetchFn(url, { ...init, headers });
9
+ }
10
+ export async function whoami(fetchFn = fetch) {
11
+ const res = await authorizedFetch(`${apiBase()}/api/v1/me`, {}, fetchFn);
12
+ if (!res.ok)
13
+ throw new Error(`GET /api/v1/me failed: HTTP ${res.status}`);
14
+ return (await res.json());
15
+ }
16
+ /**
17
+ * Resolve (and if needed cold-spawn) the session serving a framing.
18
+ * POST /api/v1/framings/{id}/session returns 200 with the session, or
19
+ * 202 {status:"spawning", retry_after_ms} - poll until 200 or timeout.
20
+ */
21
+ export async function resolveSession(framingId, opts = {}) {
22
+ const fetchFn = opts.fetchFn ?? fetch;
23
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
24
+ const now = opts.now ?? Date.now;
25
+ const deadline = now() + (opts.timeoutMs ?? 120_000);
26
+ for (;;) {
27
+ const res = await authorizedFetch(`${apiBase()}/api/v1/framings/${encodeURIComponent(framingId)}/session`, { method: "POST" }, fetchFn);
28
+ if (res.status === 200) {
29
+ return (await res.json());
30
+ }
31
+ if (res.status === 202) {
32
+ const body = (await res.json());
33
+ const wait = Math.max(500, body.retry_after_ms ?? 2000);
34
+ if (now() + wait > deadline) {
35
+ throw new Error("The session is still spawning after ~120s. Try again in a minute - cold starts can take a while.");
36
+ }
37
+ await sleep(wait);
38
+ continue;
39
+ }
40
+ if (res.status === 401 || res.status === 403) {
41
+ throw new Error(`Not authorized for framing ${framingId} (HTTP ${res.status}).`);
42
+ }
43
+ if (res.status === 404) {
44
+ throw new Error(`Framing ${framingId} was not found.`);
45
+ }
46
+ throw new Error(`Session lookup failed: HTTP ${res.status}`);
47
+ }
48
+ }
49
+ /** Session base URL where the file/engine API lives. */
50
+ export function sessionBaseUrl(session) {
51
+ const host = session.voxel_host.replace(/^https?:\/\//, "").replace(/\/+$/, "");
52
+ return `https://${host}/s/${session.session_id}`;
53
+ }
54
+ export async function fetchNativeClients(fetchFn = fetch) {
55
+ const res = await fetchFn(`${apiBase()}/api/v1/public/native-clients`);
56
+ if (!res.ok)
57
+ throw new Error(`GET /api/v1/public/native-clients failed: HTTP ${res.status}`);
58
+ const body = (await res.json());
59
+ return body.clients ?? [];
60
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * OAuth 2.0 Device Authorization Grant (RFC 8628) against Keycloak,
3
+ * plus token storage and refresh.
4
+ *
5
+ * Tokens live in ~/.config/virtualmatter/credentials.json, chmod 0600.
6
+ * The access token is refreshed via the token endpoint when it is within
7
+ * 30 seconds of expiry.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { OAUTH_CLIENT_ID, configDir, credentialsPath, oidcDiscoveryUrl } from "./config.js";
12
+ export class DeviceFlowDisabledError extends Error {
13
+ constructor() {
14
+ super("Device sign-in is not enabled yet for this environment. Ask an operator to enable the OAuth 2.0 Device Authorization Grant on the Keycloak client, then run `virtualmatter login` again.");
15
+ this.name = "DeviceFlowDisabledError";
16
+ }
17
+ }
18
+ export class NotLoggedInError extends Error {
19
+ constructor() {
20
+ super("Not signed in. Run `virtualmatter login` first.");
21
+ this.name = "NotLoggedInError";
22
+ }
23
+ }
24
+ const REFRESH_SKEW_MS = 30_000;
25
+ /** Refresh margin exported for tests. */
26
+ export { REFRESH_SKEW_MS };
27
+ async function fetchDiscovery(fetchFn) {
28
+ const res = await fetchFn(oidcDiscoveryUrl());
29
+ if (!res.ok) {
30
+ throw new Error(`OIDC discovery failed: HTTP ${res.status}`);
31
+ }
32
+ const doc = (await res.json());
33
+ if (!doc.device_authorization_endpoint || !doc.token_endpoint) {
34
+ throw new Error("OIDC discovery document is missing device_authorization_endpoint or token_endpoint");
35
+ }
36
+ return {
37
+ device_authorization_endpoint: doc.device_authorization_endpoint,
38
+ token_endpoint: doc.token_endpoint,
39
+ };
40
+ }
41
+ function form(params) {
42
+ return {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
45
+ body: new URLSearchParams(params).toString(),
46
+ };
47
+ }
48
+ /** Start the device flow: returns the codes to show the user, plus the token endpoint. */
49
+ export async function startDeviceFlow(fetchFn = fetch) {
50
+ const disco = await fetchDiscovery(fetchFn);
51
+ const res = await fetchFn(disco.device_authorization_endpoint, form({ client_id: OAUTH_CLIENT_ID, scope: "openid" }));
52
+ if (!res.ok) {
53
+ let body = {};
54
+ try {
55
+ body = (await res.json());
56
+ }
57
+ catch {
58
+ /* non-JSON error body */
59
+ }
60
+ if (body.error === "unauthorized_client") {
61
+ throw new DeviceFlowDisabledError();
62
+ }
63
+ throw new Error(`Device authorization failed: HTTP ${res.status}${body.error ? ` (${body.error}: ${body.error_description ?? ""})` : ""}`);
64
+ }
65
+ const auth = (await res.json());
66
+ return { auth, tokenEndpoint: disco.token_endpoint };
67
+ }
68
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+ /**
70
+ * Poll the token endpoint per RFC 8628 until the user approves, the code
71
+ * expires, or the server rejects. Respects `interval` and `slow_down`.
72
+ */
73
+ export async function pollForToken(tokenEndpoint, auth, opts = {}) {
74
+ const fetchFn = opts.fetchFn ?? fetch;
75
+ const sleep = opts.sleep ?? defaultSleep;
76
+ const now = opts.now ?? Date.now;
77
+ let intervalMs = Math.max(1, auth.interval ?? 5) * 1000;
78
+ const deadline = now() + auth.expires_in * 1000;
79
+ for (;;) {
80
+ if (now() > deadline) {
81
+ throw new Error("Device sign-in timed out before the code was approved. Run `virtualmatter login` again.");
82
+ }
83
+ await sleep(intervalMs);
84
+ const res = await fetchFn(tokenEndpoint, form({
85
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
86
+ device_code: auth.device_code,
87
+ client_id: OAUTH_CLIENT_ID,
88
+ }));
89
+ if (res.ok) {
90
+ const tok = (await res.json());
91
+ return {
92
+ access_token: tok.access_token,
93
+ refresh_token: tok.refresh_token,
94
+ expires_at: now() + tok.expires_in * 1000,
95
+ token_endpoint: tokenEndpoint,
96
+ };
97
+ }
98
+ let body = {};
99
+ try {
100
+ body = (await res.json());
101
+ }
102
+ catch {
103
+ /* keep polling on transient non-JSON errors */
104
+ }
105
+ switch (body.error) {
106
+ case "authorization_pending":
107
+ continue;
108
+ case "slow_down":
109
+ intervalMs += 5000;
110
+ continue;
111
+ case "expired_token":
112
+ throw new Error("The sign-in code expired before it was approved. Run `virtualmatter login` again.");
113
+ case "access_denied":
114
+ throw new Error("Sign-in was denied.");
115
+ case "unauthorized_client":
116
+ throw new DeviceFlowDisabledError();
117
+ default:
118
+ throw new Error(`Token request failed: HTTP ${res.status}${body.error ? ` (${body.error}: ${body.error_description ?? ""})` : ""}`);
119
+ }
120
+ }
121
+ }
122
+ export function saveCredentials(creds) {
123
+ const dir = configDir();
124
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ const file = credentialsPath();
126
+ const tmp = path.join(dir, `.credentials.${process.pid}.tmp`);
127
+ fs.writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
128
+ fs.renameSync(tmp, file);
129
+ fs.chmodSync(file, 0o600);
130
+ }
131
+ export function loadCredentials() {
132
+ try {
133
+ const raw = fs.readFileSync(credentialsPath(), "utf8");
134
+ return JSON.parse(raw);
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ export function clearCredentials() {
141
+ try {
142
+ fs.unlinkSync(credentialsPath());
143
+ return true;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ /**
150
+ * Return a valid access token, refreshing it when within 30s of expiry.
151
+ * Throws NotLoggedInError when no usable credentials exist.
152
+ */
153
+ export async function getAccessToken(fetchFn = fetch, now = Date.now) {
154
+ const creds = loadCredentials();
155
+ if (!creds)
156
+ throw new NotLoggedInError();
157
+ if (now() < creds.expires_at - REFRESH_SKEW_MS) {
158
+ return creds.access_token;
159
+ }
160
+ if (!creds.refresh_token)
161
+ throw new NotLoggedInError();
162
+ const res = await fetchFn(creds.token_endpoint, form({
163
+ grant_type: "refresh_token",
164
+ refresh_token: creds.refresh_token,
165
+ client_id: OAUTH_CLIENT_ID,
166
+ }));
167
+ if (!res.ok) {
168
+ throw new NotLoggedInError();
169
+ }
170
+ const tok = (await res.json());
171
+ const next = {
172
+ access_token: tok.access_token,
173
+ refresh_token: tok.refresh_token ?? creds.refresh_token,
174
+ expires_at: now() + tok.expires_in * 1000,
175
+ token_endpoint: creds.token_endpoint,
176
+ };
177
+ saveCredentials(next);
178
+ return next.access_token;
179
+ }
@@ -0,0 +1,19 @@
1
+ /** Best-effort "open this URL in a browser" with no dependencies. */
2
+ import { spawn } from "node:child_process";
3
+ export function openInBrowser(url) {
4
+ try {
5
+ const [cmd, args] = process.platform === "darwin"
6
+ ? ["open", [url]]
7
+ : process.platform === "win32"
8
+ ? ["cmd", ["/c", "start", "", url]]
9
+ : ["xdg-open", [url]];
10
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
11
+ child.on("error", () => {
12
+ /* best-effort: the URL is printed anyway */
13
+ });
14
+ child.unref();
15
+ }
16
+ catch {
17
+ /* best-effort */
18
+ }
19
+ }
package/dist/config.js ADDED
@@ -0,0 +1,22 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ /** Platform API base. Overridable so dev/staging environments work. */
4
+ export function apiBase() {
5
+ const raw = process.env.VIRTUALMATTER_API_BASE ?? "https://make.virtualmatter.ai";
6
+ return raw.replace(/\/+$/, "");
7
+ }
8
+ /** Keycloak realm OIDC discovery document URL. */
9
+ export function oidcDiscoveryUrl() {
10
+ return (process.env.VIRTUALMATTER_OIDC_DISCOVERY ??
11
+ "https://auth.atomontage.app/auth/realms/Atomontage/.well-known/openid-configuration");
12
+ }
13
+ export const OAUTH_CLIENT_ID = "virtualmatter-platform";
14
+ /** Directory where credentials live: ~/.config/virtualmatter */
15
+ export function configDir() {
16
+ return (process.env.VIRTUALMATTER_CONFIG_DIR ?? path.join(os.homedir(), ".config", "virtualmatter"));
17
+ }
18
+ export function credentialsPath() {
19
+ return path.join(configDir(), "credentials.json");
20
+ }
21
+ /** Name of the per-directory sync state file. */
22
+ export const STATE_FILE = ".virtualmatter.json";
package/dist/files.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Client for the session file + engine API served at
3
+ * https://<voxel_host>/s/<session_id>/api/montage/<framing_id>/...
4
+ */
5
+ import { authorizedFetch } from "./api.js";
6
+ export class ConflictError extends Error {
7
+ filePath;
8
+ constructor(filePath) {
9
+ super(`Conflict (412) writing ${filePath}: the remote copy changed.`);
10
+ this.filePath = filePath;
11
+ this.name = "ConflictError";
12
+ }
13
+ }
14
+ function encodePath(p) {
15
+ return p.split("/").map(encodeURIComponent).join("/");
16
+ }
17
+ /**
18
+ * The server's ETag HEADERS are RFC-9110-quoted (`"abc..."`, possibly
19
+ * weak `W/"abc..."`) while its listing JSON and write responses carry
20
+ * bare sha256 hex. Everything we store and compare must be the bare
21
+ * form - storing the quoted header verbatim made every listing
22
+ * comparison miss and sent sync into an eternal re-pull loop (caught
23
+ * live on the first end-to-end run, 2026-08-28).
24
+ */
25
+ export function normalizeEtag(v) {
26
+ if (v === null)
27
+ return null;
28
+ let s = v.trim();
29
+ if (s.startsWith("W/"))
30
+ s = s.slice(2).trim();
31
+ if (s.length >= 2 && s.startsWith('"') && s.endsWith('"'))
32
+ s = s.slice(1, -1);
33
+ return s;
34
+ }
35
+ export class FilesClient {
36
+ sessionBase;
37
+ framingId;
38
+ fetchFn;
39
+ constructor(sessionBase, framingId, fetchFn = fetch) {
40
+ this.sessionBase = sessionBase;
41
+ this.framingId = framingId;
42
+ this.fetchFn = fetchFn;
43
+ }
44
+ get apiRoot() {
45
+ return `${this.sessionBase}/api/montage/${encodeURIComponent(this.framingId)}`;
46
+ }
47
+ async list() {
48
+ const res = await authorizedFetch(`${this.apiRoot}/files`, {}, this.fetchFn);
49
+ if (!res.ok)
50
+ throw new Error(`Listing files failed: HTTP ${res.status}`);
51
+ // Server envelope: {files: [...], truncated: bool} (session-agent
52
+ // files API). Truncation only trips past 20k files; surface it so a
53
+ // giant world doesn't silently half-sync.
54
+ const body = (await res.json());
55
+ if (body.truncated) {
56
+ console.error("Warning: the server truncated the file listing (very large world) - sync may be incomplete.");
57
+ }
58
+ return body.files;
59
+ }
60
+ async get(filePath) {
61
+ const res = await authorizedFetch(`${this.apiRoot}/files/content/${encodePath(filePath)}`, {}, this.fetchFn);
62
+ if (!res.ok)
63
+ throw new Error(`Downloading ${filePath} failed: HTTP ${res.status}`);
64
+ const bytes = Buffer.from(await res.arrayBuffer());
65
+ return { bytes, etag: normalizeEtag(res.headers.get("ETag")) };
66
+ }
67
+ async put(filePath, body, opts) {
68
+ const headers = { "Content-Type": "application/octet-stream" };
69
+ if (opts.createOnly)
70
+ headers["If-None-Match"] = "*";
71
+ else if (opts.etag)
72
+ headers["If-Match"] = opts.etag;
73
+ const res = await authorizedFetch(`${this.apiRoot}/files/content/${encodePath(filePath)}`, { method: "PUT", headers, body: new Uint8Array(body) }, this.fetchFn);
74
+ if (res.status === 412)
75
+ throw new ConflictError(filePath);
76
+ if (!res.ok)
77
+ throw new Error(`Uploading ${filePath} failed: HTTP ${res.status}`);
78
+ const parsed = (await res.json());
79
+ return parsed.etag;
80
+ }
81
+ async delete(filePath, etag) {
82
+ const res = await authorizedFetch(`${this.apiRoot}/files/content/${encodePath(filePath)}`, { method: "DELETE", headers: { "If-Match": etag } }, this.fetchFn);
83
+ if (res.status === 412)
84
+ throw new ConflictError(filePath);
85
+ if (!res.ok && res.status !== 404) {
86
+ throw new Error(`Deleting ${filePath} failed: HTTP ${res.status}`);
87
+ }
88
+ }
89
+ async runLua(code, target) {
90
+ const res = await authorizedFetch(`${this.apiRoot}/engine/run-lua`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({ code, target }),
94
+ }, this.fetchFn);
95
+ if (!res.ok)
96
+ throw new Error(`run-lua failed: HTTP ${res.status}`);
97
+ // Server envelope: {output: <text>} - the engine bridge's text
98
+ // (result/print/error sections) passed through verbatim.
99
+ const body = (await res.json());
100
+ return body.output;
101
+ }
102
+ async engineErrors() {
103
+ const res = await authorizedFetch(`${this.apiRoot}/engine/errors`, {}, this.fetchFn);
104
+ if (!res.ok)
105
+ throw new Error(`Fetching engine errors failed: HTTP ${res.status}`);
106
+ const body = (await res.json());
107
+ return body.output;
108
+ }
109
+ async screenshot() {
110
+ const res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, { method: "POST" }, this.fetchFn);
111
+ if (!res.ok)
112
+ throw new Error(`Screenshot failed: HTTP ${res.status}`);
113
+ return Buffer.from(await res.arrayBuffer());
114
+ }
115
+ }
package/dist/ignore.js ADDED
@@ -0,0 +1,23 @@
1
+ /** Shared path-exclusion rules for pull + sync. */
2
+ import { STATE_FILE } from "./config.js";
3
+ /** Server-side folders never synced. */
4
+ const EXCLUDED_DIRS = ["Uploads", "Screenshots", "Agent Logs"];
5
+ export function isIgnoredPath(relPath) {
6
+ const norm = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
7
+ if (norm.length === 0)
8
+ return true;
9
+ const segments = norm.split("/");
10
+ const first = segments[0];
11
+ if (EXCLUDED_DIRS.includes(first))
12
+ return true;
13
+ // Dotfiles anywhere in the path (covers .virtualmatter.json, .git, .DS_Store).
14
+ if (segments.some((s) => s.startsWith(".")))
15
+ return true;
16
+ if (norm === STATE_FILE)
17
+ return true;
18
+ if (norm === "AGENTS.md")
19
+ return true;
20
+ if (norm.endsWith(".remote-conflict"))
21
+ return true;
22
+ return false;
23
+ }
package/dist/index.js ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /** virtualmatter - the CLI coding agents (and humans) install to build with Virtual Matter. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { Command } from "commander";
6
+ import { resolveSession, sessionBaseUrl, fetchNativeClients, whoami } from "./api.js";
7
+ import { clearCredentials, loadCredentials, pollForToken, saveCredentials, startDeviceFlow, } from "./auth.js";
8
+ import { openInBrowser } from "./browser.js";
9
+ import { FilesClient } from "./files.js";
10
+ import { runMcpServer, resolveFramingId } from "./mcp.js";
11
+ import { pullCommand } from "./pull.js";
12
+ import { requireState } from "./state.js";
13
+ import { SyncEngine, runSyncLoop } from "./sync.js";
14
+ import { parseTarget, PROJECT_URL_HELP } from "./urls.js";
15
+ const program = new Command();
16
+ program
17
+ .name("virtualmatter")
18
+ .description("Build with Virtual Matter from your terminal: sync montage files, run Lua, capture screenshots, and expose it all to coding agents over MCP.")
19
+ .version("0.1.0");
20
+ function fail(message) {
21
+ console.error(message);
22
+ process.exit(1);
23
+ }
24
+ async function run(fn) {
25
+ try {
26
+ await fn();
27
+ }
28
+ catch (err) {
29
+ fail(err instanceof Error ? err.message : String(err));
30
+ }
31
+ }
32
+ /** Resolve a session-scoped FilesClient from a synced dir's state file. */
33
+ async function clientForDir(dir) {
34
+ const state = requireState(dir);
35
+ const session = await resolveSession(state.framing_id);
36
+ return { client: new FilesClient(sessionBaseUrl(session), state.framing_id), framingId: state.framing_id };
37
+ }
38
+ program
39
+ .command("login")
40
+ .description("Sign in with Virtual Matter using a device code")
41
+ .action(() => run(async () => {
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
+ }));
52
+ program
53
+ .command("logout")
54
+ .description("Forget the stored credentials")
55
+ .action(() => {
56
+ const removed = clearCredentials();
57
+ console.log(removed ? "Signed out." : "No stored credentials.");
58
+ });
59
+ program
60
+ .command("whoami")
61
+ .description("Show who is signed in")
62
+ .action(() => run(async () => {
63
+ if (!loadCredentials())
64
+ fail("Not signed in. Run `virtualmatter login` first.");
65
+ const me = await whoami();
66
+ console.log(JSON.stringify(me, null, 2));
67
+ }));
68
+ program
69
+ .command("pull")
70
+ .description("Download a framing's file tree into a local folder")
71
+ .argument("<target>", "an /edit, /play, or /m URL - or a bare framing id")
72
+ .argument("[dir]", "destination folder (default: ./<framing-id>)")
73
+ .action((target, dir) => run(async () => {
74
+ const parsed = parseTarget(target);
75
+ if (parsed.kind === "project")
76
+ fail(PROJECT_URL_HELP);
77
+ if (parsed.kind === "invalid")
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`);
83
+ }));
84
+ program
85
+ .command("sync")
86
+ .description("Two-way sync: watch the folder, push local edits, pull remote changes")
87
+ .argument("[dir]", "a folder previously created by `virtualmatter pull`", ".")
88
+ .action((dir) => run(async () => {
89
+ const abs = path.resolve(dir);
90
+ const state = requireState(abs);
91
+ const session = await resolveSession(state.framing_id);
92
+ const client = new FilesClient(sessionBaseUrl(session), state.framing_id);
93
+ const engine = new SyncEngine(client, abs, state);
94
+ console.log(`Syncing ${abs} with framing ${state.framing_id}. Ctrl-C to stop.`);
95
+ await runSyncLoop(engine, abs);
96
+ }));
97
+ program
98
+ .command("run-lua")
99
+ .description("Execute Lua in the running engine")
100
+ .argument("[dir]", "a synced folder", ".")
101
+ .requiredOption("--code <lua>", "Lua source to execute")
102
+ .option("--target <target>", "server or client", "server")
103
+ .action((dir, opts) => run(async () => {
104
+ if (opts.target !== "server" && opts.target !== "client") {
105
+ fail("--target must be server or client");
106
+ }
107
+ const { client } = await clientForDir(path.resolve(dir));
108
+ const result = await client.runLua(opts.code, opts.target);
109
+ console.log(JSON.stringify({ result }, null, 2));
110
+ }));
111
+ program
112
+ .command("errors")
113
+ .description("Show recent engine errors")
114
+ .argument("[dir]", "a synced folder", ".")
115
+ .action((dir) => run(async () => {
116
+ const { client } = await clientForDir(path.resolve(dir));
117
+ console.log(JSON.stringify(await client.engineErrors(), null, 2));
118
+ }));
119
+ program
120
+ .command("screenshot")
121
+ .description("Capture a PNG of the engine's current view")
122
+ .argument("[dir]", "a synced folder", ".")
123
+ .option("-o, --out <file>", "output file", "screenshot.png")
124
+ .action((dir, opts) => run(async () => {
125
+ const { client } = await clientForDir(path.resolve(dir));
126
+ const png = await client.screenshot();
127
+ fs.writeFileSync(opts.out, png);
128
+ console.log(`Wrote ${opts.out} (${png.length} bytes).`);
129
+ }));
130
+ program
131
+ .command("client")
132
+ .description("Print the native client download URL for this OS")
133
+ .action(() => run(async () => {
134
+ const clients = await fetchNativeClients();
135
+ const platform = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux";
136
+ const matches = clients.filter((c) => c.platform.toLowerCase().includes(platform));
137
+ if (matches.length === 0) {
138
+ console.log(`No native client published for ${platform} yet. All available:`);
139
+ for (const c of clients)
140
+ console.log(` ${c.platform}${c.kind ? ` (${c.kind})` : ""}: ${c.url}`);
141
+ return;
142
+ }
143
+ for (const c of matches)
144
+ console.log(`${c.platform}${c.kind ? ` (${c.kind})` : ""}: ${c.url}`);
145
+ }));
146
+ program
147
+ .command("mcp")
148
+ .description("Run the stdio MCP server for coding agents")
149
+ .argument("[dir]", "a synced folder whose state file names the framing", ".")
150
+ .option("--framing <id>", "framing id (overrides the state file)")
151
+ .action((dir, opts) => run(async () => {
152
+ const abs = path.resolve(dir);
153
+ // Validate early so misconfiguration fails loudly at registration time.
154
+ resolveFramingId(abs, opts.framing);
155
+ await runMcpServer(abs, opts.framing);
156
+ }));
157
+ program.parseAsync(process.argv).catch((err) => {
158
+ fail(err instanceof Error ? err.message : String(err));
159
+ });
package/dist/mcp.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `virtualmatter mcp` - a stdio MCP server that gives a coding agent direct
3
+ * hands on a live Virtual Matter session: montage files, Lua execution,
4
+ * engine errors, and screenshots.
5
+ */
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+ import { resolveSession, sessionBaseUrl } from "./api.js";
10
+ import { apiBase } from "./config.js";
11
+ import { ConflictError, FilesClient } from "./files.js";
12
+ import { loadState } from "./state.js";
13
+ export function resolveFramingId(dir, framingFlag) {
14
+ if (framingFlag)
15
+ return framingFlag;
16
+ const state = loadState(dir);
17
+ if (state)
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>.`);
20
+ }
21
+ function makeContext(framingId) {
22
+ let session = null;
23
+ let client = null;
24
+ const getSession = async () => {
25
+ if (!session)
26
+ session = await resolveSession(framingId);
27
+ return session;
28
+ };
29
+ return {
30
+ framingId,
31
+ getSession,
32
+ async getClient() {
33
+ if (!client)
34
+ client = new FilesClient(sessionBaseUrl(await getSession()), framingId);
35
+ return client;
36
+ },
37
+ };
38
+ }
39
+ function textResult(text) {
40
+ return { content: [{ type: "text", text }] };
41
+ }
42
+ /**
43
+ * write_file etag handling: look up the current etag from a listing, PUT with
44
+ * If-Match (or If-None-Match: * for a new file), and on 412 retry ONCE with a
45
+ * fresh etag - the agent supplied the full desired content, so last-write-wins
46
+ * on retry is the intended semantics.
47
+ */
48
+ export async function writeWithRetry(client, filePath, body) {
49
+ const findEtag = async () => {
50
+ const listing = await client.list();
51
+ return listing.find((f) => f.path === filePath)?.etag;
52
+ };
53
+ let etag = await findEtag();
54
+ try {
55
+ return await client.put(filePath, body, etag ? { etag } : { createOnly: true });
56
+ }
57
+ catch (err) {
58
+ if (!(err instanceof ConflictError))
59
+ throw err;
60
+ etag = await findEtag();
61
+ return await client.put(filePath, body, etag ? { etag } : { createOnly: true });
62
+ }
63
+ }
64
+ export async function runMcpServer(dir, framingFlag) {
65
+ const framingId = resolveFramingId(dir, framingFlag);
66
+ const ctx = makeContext(framingId);
67
+ const server = new McpServer({ name: "virtualmatter", version: "0.1.0" });
68
+ server.registerTool("list_files", {
69
+ 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
+ inputSchema: {},
71
+ }, async () => {
72
+ const client = await ctx.getClient();
73
+ return textResult(JSON.stringify(await client.list(), null, 2));
74
+ });
75
+ server.registerTool("read_file", {
76
+ description: "Read one file from the live session's Montage tree. Pass the exact relative path from list_files (e.g. \"Scripts/main.lua\"). Returns the file content as text.",
77
+ inputSchema: { path: z.string().describe("Relative file path inside the montage tree") },
78
+ }, async ({ path: filePath }) => {
79
+ const client = await ctx.getClient();
80
+ const { bytes } = await client.get(filePath);
81
+ return textResult(bytes.toString("utf8"));
82
+ });
83
+ server.registerTool("write_file", {
84
+ description: "Write (create or overwrite) one file in the live session's Montage tree with the full desired content. Concurrency is handled internally with etags - if someone else changed the file since you read it, the write retries once against the latest version and your content wins. Lua scripts hot-reload in the engine on write, so writing a script IS deploying it.",
85
+ inputSchema: {
86
+ path: z.string().describe("Relative file path inside the montage tree"),
87
+ content: z.string().describe("Full new file content (UTF-8 text)"),
88
+ },
89
+ }, async ({ path: filePath, content }) => {
90
+ const client = await ctx.getClient();
91
+ const etag = await writeWithRetry(client, filePath, Buffer.from(content, "utf8"));
92
+ return textResult(JSON.stringify({ ok: true, path: filePath, etag }));
93
+ });
94
+ server.registerTool("run_lua", {
95
+ description: "Execute a Lua snippet in the running Virtual Matter engine and return its result. target \"server\" (default) runs in the server-side Lua state where game logic lives; \"client\" runs in the connected client. Use this to inspect live state, call engine APIs, or nudge the scene without editing files.",
96
+ inputSchema: {
97
+ code: z.string().describe("Lua source to execute"),
98
+ target: z
99
+ .enum(["server", "client"])
100
+ .optional()
101
+ .describe("Which Lua state to run in (default: server)"),
102
+ },
103
+ }, async ({ code, target }) => {
104
+ const client = await ctx.getClient();
105
+ const result = await client.runLua(code, target ?? "server");
106
+ return textResult(JSON.stringify({ result }, null, 2));
107
+ });
108
+ server.registerTool("get_engine_errors", {
109
+ description: "Fetch recent Lua/engine errors from the running session. Check this after writing a script or running Lua - a hot-reload that failed shows up here, not in the write result.",
110
+ inputSchema: {},
111
+ }, async () => {
112
+ const client = await ctx.getClient();
113
+ return textResult(JSON.stringify(await client.engineErrors(), null, 2));
114
+ });
115
+ server.registerTool("capture_screenshot", {
116
+ description: "Capture a PNG screenshot of the engine's current view and return it as an image. Use it to visually verify what a change actually looks like in the world.",
117
+ inputSchema: {},
118
+ }, async () => {
119
+ const client = await ctx.getClient();
120
+ const png = await client.screenshot();
121
+ return {
122
+ content: [
123
+ { type: "image", data: png.toString("base64"), mimeType: "image/png" },
124
+ ],
125
+ };
126
+ });
127
+ server.registerTool("world_info", {
128
+ description: "Return the framing id plus the URLs for this session: the editor and play links a human can open, and the session API base this server talks to.",
129
+ inputSchema: {},
130
+ }, async () => {
131
+ const session = await ctx.getSession();
132
+ return textResult(JSON.stringify({
133
+ framing_id: ctx.framingId,
134
+ edit_url: `${apiBase()}/edit/${ctx.framingId}`,
135
+ play_url: `${apiBase()}/play/${ctx.framingId}`,
136
+ session_api_base: sessionBaseUrl(session),
137
+ voxel_host: session.voxel_host,
138
+ session_id: session.session_id,
139
+ }, null, 2));
140
+ });
141
+ const transport = new StdioServerTransport();
142
+ await server.connect(transport);
143
+ // Keep the process alive until the transport closes.
144
+ await new Promise((resolve) => {
145
+ transport.onclose = () => resolve();
146
+ });
147
+ }
package/dist/pull.js ADDED
@@ -0,0 +1,62 @@
1
+ /** `virtualmatter pull`: download a framing's file tree and seed the state file. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveSession, sessionBaseUrl } from "./api.js";
5
+ import { apiBase } from "./config.js";
6
+ import { FilesClient } from "./files.js";
7
+ import { isIgnoredPath } from "./ignore.js";
8
+ import { saveState } from "./state.js";
9
+ const AGENTS_MD_STUB = `# Working with Virtual Matter
10
+
11
+ This folder is a live mirror of a Virtual Matter framing's Montage files.
12
+
13
+ - \`virtualmatter sync\` keeps it in sync with the running session.
14
+ - \`virtualmatter run-lua --code "..."\` executes Lua in the engine.
15
+ - \`virtualmatter errors\` shows recent engine errors.
16
+ - \`virtualmatter screenshot -o shot.png\` captures the current view.
17
+ - Lua scripts under this tree hot-reload in the engine when saved.
18
+
19
+ Do not edit \`.virtualmatter.json\` - it is sync bookkeeping.
20
+ `;
21
+ export async function fetchAgentsMd(fetchFn = fetch) {
22
+ try {
23
+ const res = await fetchFn(`${apiBase()}/AGENTS.md`);
24
+ if (res.ok) {
25
+ const text = await res.text();
26
+ if (text.trim().length > 0 && !text.trimStart().startsWith("<"))
27
+ return text;
28
+ }
29
+ }
30
+ catch {
31
+ /* fall through to the stub */
32
+ }
33
+ return AGENTS_MD_STUB;
34
+ }
35
+ export async function pullTree(client, dir, framingId) {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ const remote = await client.list();
38
+ const state = { framing_id: framingId, etags: {} };
39
+ let count = 0;
40
+ for (const file of remote) {
41
+ if (isIgnoredPath(file.path))
42
+ continue;
43
+ const { bytes, etag } = await client.get(file.path);
44
+ const absPath = path.join(dir, file.path);
45
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
46
+ fs.writeFileSync(absPath, bytes);
47
+ state.etags[file.path] = etag ?? file.etag;
48
+ count++;
49
+ }
50
+ saveState(dir, state);
51
+ return { dir, framingId, fileCount: count };
52
+ }
53
+ export async function pullCommand(framingId, dir) {
54
+ console.log(`Resolving session for framing ${framingId} (this can cold-start one) ...`);
55
+ const session = await resolveSession(framingId);
56
+ const client = new FilesClient(sessionBaseUrl(session), framingId);
57
+ console.log(`Session ready at ${sessionBaseUrl(session)}. Downloading files ...`);
58
+ const result = await pullTree(client, dir, framingId);
59
+ const agentsMd = await fetchAgentsMd();
60
+ fs.writeFileSync(path.join(dir, "AGENTS.md"), agentsMd);
61
+ return result;
62
+ }
package/dist/state.js ADDED
@@ -0,0 +1,32 @@
1
+ /** The .virtualmatter.json state file: framing id + per-file etag bookkeeping. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { STATE_FILE } from "./config.js";
5
+ export function statePath(dir) {
6
+ return path.join(dir, STATE_FILE);
7
+ }
8
+ export function loadState(dir) {
9
+ try {
10
+ const raw = fs.readFileSync(statePath(dir), "utf8");
11
+ const parsed = JSON.parse(raw);
12
+ if (typeof parsed.framing_id !== "string")
13
+ return null;
14
+ return { framing_id: parsed.framing_id, etags: parsed.etags ?? {} };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function saveState(dir, state) {
21
+ const tmp = statePath(dir) + ".tmp";
22
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
23
+ fs.renameSync(tmp, statePath(dir));
24
+ }
25
+ /** Load the state file or exit with guidance. */
26
+ export function requireState(dir) {
27
+ const state = loadState(dir);
28
+ if (!state) {
29
+ throw new Error(`No ${STATE_FILE} found in ${dir}. Run \`virtualmatter pull <url-or-framing-id> ${dir}\` first, or pass --framing <id>.`);
30
+ }
31
+ return state;
32
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The sync hot loop: initial pull of remote changes, watch the local tree
3
+ * and push edits with stored etags, poll the remote listing and pull files
4
+ * whose etag changed remotely. Conflicts (412) never kill the loop - the
5
+ * remote version lands next to yours as <name>.remote-conflict.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { ConflictError } from "./files.js";
10
+ import { isIgnoredPath } from "./ignore.js";
11
+ import { loadState, saveState } from "./state.js";
12
+ const consoleLogger = {
13
+ info: (m) => console.log(m),
14
+ warn: (m) => console.warn(m),
15
+ };
16
+ /** How long after our own local write of a pulled file we ignore watcher events for it. */
17
+ const SELF_WRITE_GRACE_MS = 2000;
18
+ export class SyncEngine {
19
+ client;
20
+ dir;
21
+ log;
22
+ now;
23
+ state;
24
+ /** relPath -> timestamp of a write WE made (pull), so the watcher skips it. */
25
+ selfWrites = new Map();
26
+ constructor(client, dir, state, opts = {}) {
27
+ this.client = client;
28
+ this.dir = dir;
29
+ this.log = opts.logger ?? consoleLogger;
30
+ this.now = opts.now ?? Date.now;
31
+ this.state = state;
32
+ }
33
+ get framingId() {
34
+ return this.state.framing_id;
35
+ }
36
+ abs(relPath) {
37
+ return path.join(this.dir, relPath);
38
+ }
39
+ persist() {
40
+ saveState(this.dir, this.state);
41
+ }
42
+ recordSelfWrite(relPath) {
43
+ this.selfWrites.set(relPath, this.now());
44
+ }
45
+ isSelfWrite(relPath) {
46
+ const t = this.selfWrites.get(relPath);
47
+ if (t === undefined)
48
+ return false;
49
+ if (this.now() - t > SELF_WRITE_GRACE_MS) {
50
+ this.selfWrites.delete(relPath);
51
+ return false;
52
+ }
53
+ return true;
54
+ }
55
+ async pullFile(relPath, etag) {
56
+ const { bytes, etag: gotEtag } = await this.client.get(relPath);
57
+ const absPath = this.abs(relPath);
58
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
59
+ this.recordSelfWrite(relPath);
60
+ fs.writeFileSync(absPath, bytes);
61
+ this.state.etags[relPath] = gotEtag ?? etag;
62
+ this.persist();
63
+ }
64
+ /**
65
+ * Initial reconcile: pull every remote file that is new or whose etag
66
+ * changed since the last sync, and push local files the remote has never
67
+ * seen. Returns counts for logging/tests.
68
+ */
69
+ async initialSync() {
70
+ const remote = await this.client.list();
71
+ let pulled = 0;
72
+ let pushed = 0;
73
+ const remotePaths = new Set();
74
+ for (const file of remote) {
75
+ if (isIgnoredPath(file.path))
76
+ continue;
77
+ remotePaths.add(file.path);
78
+ const known = this.state.etags[file.path];
79
+ const localExists = fs.existsSync(this.abs(file.path));
80
+ if (known !== file.etag || !localExists) {
81
+ await this.pullFile(file.path, file.etag);
82
+ pulled++;
83
+ }
84
+ }
85
+ // Local files the remote has never seen get created remotely.
86
+ for (const relPath of this.walkLocal()) {
87
+ if (remotePaths.has(relPath))
88
+ continue;
89
+ if (this.state.etags[relPath] !== undefined)
90
+ continue; // was remote once; treat as remote delete, leave local alone
91
+ await this.pushFile(relPath, { createOnly: true });
92
+ pushed++;
93
+ }
94
+ return { pulled, pushed };
95
+ }
96
+ *walkLocal(rel = "") {
97
+ const absDir = path.join(this.dir, rel);
98
+ let entries;
99
+ try {
100
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ for (const entry of entries) {
106
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
107
+ if (isIgnoredPath(relPath))
108
+ continue;
109
+ if (entry.isDirectory())
110
+ yield* this.walkLocal(relPath);
111
+ else if (entry.isFile())
112
+ yield relPath;
113
+ }
114
+ }
115
+ async pushFile(relPath, opts) {
116
+ const body = fs.readFileSync(this.abs(relPath));
117
+ const known = this.state.etags[relPath];
118
+ const createOnly = opts?.createOnly ?? known === undefined;
119
+ try {
120
+ const etag = await this.client.put(relPath, body, createOnly ? { createOnly: true } : { etag: known });
121
+ this.state.etags[relPath] = etag;
122
+ this.persist();
123
+ this.log.info(`pushed ${relPath}`);
124
+ }
125
+ catch (err) {
126
+ if (err instanceof ConflictError) {
127
+ await this.handleConflict(relPath);
128
+ return;
129
+ }
130
+ throw err;
131
+ }
132
+ }
133
+ /** 412: fetch the remote version to <name>.remote-conflict, keep the loop alive. */
134
+ async handleConflict(relPath) {
135
+ try {
136
+ const { bytes, etag } = await this.client.get(relPath);
137
+ const conflictPath = this.abs(relPath) + ".remote-conflict";
138
+ fs.mkdirSync(path.dirname(conflictPath), { recursive: true });
139
+ fs.writeFileSync(conflictPath, bytes);
140
+ if (etag)
141
+ this.state.etags[relPath] = etag;
142
+ // Deliberately NOT overwriting the local file - the user resolves.
143
+ this.persist();
144
+ this.log.warn(`conflict on ${relPath}: the remote copy changed while you edited it. ` +
145
+ `Remote version saved as ${relPath}.remote-conflict - merge, save, and it will push.`);
146
+ }
147
+ catch (err) {
148
+ this.log.warn(`conflict on ${relPath}, and fetching the remote version failed: ${String(err)}`);
149
+ }
150
+ }
151
+ /** Watcher callback for a local add/change. */
152
+ async handleLocalChange(relPath) {
153
+ const norm = relPath.replace(/\\/g, "/");
154
+ if (isIgnoredPath(norm))
155
+ return;
156
+ if (this.isSelfWrite(norm))
157
+ return;
158
+ if (!fs.existsSync(this.abs(norm)))
159
+ return;
160
+ try {
161
+ await this.pushFile(norm);
162
+ }
163
+ catch (err) {
164
+ this.log.warn(`push of ${norm} failed: ${String(err)}`);
165
+ }
166
+ }
167
+ /** Watcher callback for a local delete. */
168
+ async handleLocalDelete(relPath) {
169
+ const norm = relPath.replace(/\\/g, "/");
170
+ if (isIgnoredPath(norm))
171
+ return;
172
+ if (this.isSelfWrite(norm))
173
+ return;
174
+ const known = this.state.etags[norm];
175
+ if (known === undefined)
176
+ return;
177
+ try {
178
+ await this.client.delete(norm, known);
179
+ delete this.state.etags[norm];
180
+ this.persist();
181
+ this.log.info(`deleted ${norm} remotely`);
182
+ }
183
+ catch (err) {
184
+ if (err instanceof ConflictError) {
185
+ await this.handleConflict(norm);
186
+ return;
187
+ }
188
+ this.log.warn(`remote delete of ${norm} failed: ${String(err)}`);
189
+ }
190
+ }
191
+ /**
192
+ * One remote poll tick: pull files whose remote etag differs from our
193
+ * bookkeeping. Files we just pushed match by etag, so they are skipped
194
+ * naturally. Returns the pulled paths.
195
+ */
196
+ async pollOnce() {
197
+ const remote = await this.client.list();
198
+ const pulled = [];
199
+ for (const file of remote) {
200
+ if (isIgnoredPath(file.path))
201
+ continue;
202
+ if (this.state.etags[file.path] === file.etag && fs.existsSync(this.abs(file.path)))
203
+ continue;
204
+ await this.pullFile(file.path, file.etag);
205
+ pulled.push(file.path);
206
+ this.log.info(`pulled ${file.path}`);
207
+ }
208
+ return pulled;
209
+ }
210
+ }
211
+ /** Run the full watch loop until SIGINT. Not unit-tested directly; the decisions above are. */
212
+ export async function runSyncLoop(engine, dir) {
213
+ const { default: chokidar } = await import("chokidar");
214
+ const { pulled, pushed } = await engine.initialSync();
215
+ console.log(`initial sync: ${pulled} pulled, ${pushed} pushed. Watching ${dir} ...`);
216
+ const watcher = chokidar.watch(dir, {
217
+ ignoreInitial: true,
218
+ ignored: (p) => {
219
+ const rel = path.relative(dir, p).replace(/\\/g, "/");
220
+ return rel.length > 0 && isIgnoredPath(rel);
221
+ },
222
+ });
223
+ let queue = Promise.resolve();
224
+ const enqueue = (fn) => {
225
+ queue = queue.then(fn).catch((err) => console.warn(String(err)));
226
+ };
227
+ watcher.on("add", (p) => enqueue(() => engine.handleLocalChange(path.relative(dir, p))));
228
+ watcher.on("change", (p) => enqueue(() => engine.handleLocalChange(path.relative(dir, p))));
229
+ watcher.on("unlink", (p) => enqueue(() => engine.handleLocalDelete(path.relative(dir, p))));
230
+ const interval = setInterval(() => {
231
+ enqueue(async () => {
232
+ try {
233
+ await engine.pollOnce();
234
+ }
235
+ catch (err) {
236
+ console.warn(`remote poll failed: ${String(err)}`);
237
+ }
238
+ });
239
+ }, 3000);
240
+ await new Promise((resolve) => {
241
+ const stop = async () => {
242
+ clearInterval(interval);
243
+ await watcher.close();
244
+ console.log("\nsync stopped.");
245
+ resolve();
246
+ };
247
+ process.once("SIGINT", stop);
248
+ process.once("SIGTERM", stop);
249
+ });
250
+ }
251
+ export { loadState };
package/dist/urls.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Parse the URL shapes a user may paste and extract a framing id.
3
+ *
4
+ * Framing-shaped (id resolves locally):
5
+ * /edit/<framingId> /play/<framingId> /m/<framingId>
6
+ * Project-shaped (v1 does not resolve these):
7
+ * /g/<montageId> /play/p/<montageId>
8
+ * Accepted on any virtualmatter.ai / virtualmatter.dev host, and a bare
9
+ * framing id (UUID) is accepted as-is.
10
+ */
11
+ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
12
+ function isVirtualmatterHost(host) {
13
+ const h = host.toLowerCase();
14
+ return (h === "virtualmatter.ai" ||
15
+ h.endsWith(".virtualmatter.ai") ||
16
+ h === "virtualmatter.dev" ||
17
+ h.endsWith(".virtualmatter.dev") ||
18
+ h === "localhost" ||
19
+ h === "127.0.0.1");
20
+ }
21
+ // Framing ids are nanoids (URL-safe alphabet, typically 21 chars),
22
+ // case-SENSITIVE - never lowercase them. 15-32 keeps obvious typos and
23
+ // full URLs out while accepting every id shape the platform mints.
24
+ const NANOID_RE = /^[A-Za-z0-9_-]{15,32}$/;
25
+ export function parseTarget(input) {
26
+ const trimmed = input.trim();
27
+ if (UUID_RE.test(trimmed)) {
28
+ return { kind: "framing", framingId: trimmed.toLowerCase() };
29
+ }
30
+ if (NANOID_RE.test(trimmed) && !trimmed.includes(".")) {
31
+ return { kind: "framing", framingId: trimmed };
32
+ }
33
+ let url;
34
+ try {
35
+ url = new URL(trimmed);
36
+ }
37
+ catch {
38
+ return { kind: "invalid", reason: `Not a framing id or URL: ${trimmed}` };
39
+ }
40
+ if (!isVirtualmatterHost(url.hostname)) {
41
+ return { kind: "invalid", reason: `Not a Virtual Matter host: ${url.hostname}` };
42
+ }
43
+ const parts = url.pathname.split("/").filter((p) => p.length > 0);
44
+ // Project-level shapes first: /g/<montageId> and /play/p/<montageId>
45
+ if (parts.length >= 2 && parts[0] === "g" && parts[1]) {
46
+ return { kind: "project", montageId: parts[1] };
47
+ }
48
+ if (parts.length >= 3 && parts[0] === "play" && parts[1] === "p" && parts[2]) {
49
+ return { kind: "project", montageId: parts[2] };
50
+ }
51
+ // Framing shapes: /edit/<id>, /play/<id>, /m/<id>
52
+ if (parts.length >= 2 && parts[1] && ["edit", "play", "m"].includes(parts[0])) {
53
+ const id = parts[1];
54
+ if (UUID_RE.test(id)) {
55
+ return { kind: "framing", framingId: id.toLowerCase() };
56
+ }
57
+ return { kind: "framing", framingId: id };
58
+ }
59
+ return { kind: "invalid", reason: `Unrecognized Virtual Matter URL path: ${url.pathname}` };
60
+ }
61
+ /** Human guidance for project-shaped URLs (v1 keeps it simple). */
62
+ export const PROJECT_URL_HELP = "That link points at a whole project, not a single framing. Open the project in your browser and copy the /edit/<id> link for the framing you want, then try again.";
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "virtualmatter",
3
+ "version": "0.1.0",
4
+ "description": "CLI + MCP server for building with Virtual Matter - sync montage files, run Lua, capture screenshots, and wire coding agents into a live session.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "virtualmatter": "./dist/index.js",
9
+ "vm": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.12.0",
26
+ "chokidar": "^4.0.3",
27
+ "commander": "^14.0.0",
28
+ "zod": "^3.25.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.15.0",
32
+ "typescript": "^5.8.0",
33
+ "vitest": "^3.2.0"
34
+ },
35
+ "homepage": "https://make.virtualmatter.ai/developers",
36
+ "keywords": [
37
+ "voxel",
38
+ "game-development",
39
+ "mcp",
40
+ "ai-agents",
41
+ "virtualmatter",
42
+ "atomontage"
43
+ ]
44
+ }