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 +21 -0
- package/README.md +91 -0
- package/dist/api.js +60 -0
- package/dist/auth.js +179 -0
- package/dist/browser.js +19 -0
- package/dist/config.js +22 -0
- package/dist/files.js +115 -0
- package/dist/ignore.js +23 -0
- package/dist/index.js +159 -0
- package/dist/mcp.js +147 -0
- package/dist/pull.js +62 -0
- package/dist/state.js +32 -0
- package/dist/sync.js +251 -0
- package/dist/urls.js +62 -0
- package/package.json +44 -0
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
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -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
|
+
}
|