virtualmatter 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -34
- package/dist/agentfiles.js +114 -0
- package/dist/api.js +114 -0
- package/dist/auth.js +10 -4
- package/dist/client.js +317 -0
- package/dist/config.js +23 -0
- package/dist/files.js +53 -5
- package/dist/ignore.js +22 -1
- package/dist/index.js +229 -52
- package/dist/mcp.js +157 -26
- package/dist/pull.js +58 -37
- package/dist/resolve.js +65 -0
- package/dist/urls.js +80 -25
- package/package.json +4 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native client, without the human in the loop: pick the build for this
|
|
3
|
+
* OS, download it, unpack it, hand it the world URL, and sign it in with the
|
|
4
|
+
* credentials the CLI already holds.
|
|
5
|
+
*
|
|
6
|
+
* Builds are unpacked under <configDir>/client/<dir_uid>/ so re-running
|
|
7
|
+
* `open` is instant, and a newer build lands next to the old one instead
|
|
8
|
+
* of over it. Archives are removed after extraction.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fetchNativeClientCatalog } from "./api.js";
|
|
15
|
+
import { loadCredentials } from "./auth.js";
|
|
16
|
+
import { OAUTH_CLIENT_ID, clientInstallRoot, oidcDiscoveryUrl } from "./config.js";
|
|
17
|
+
export function detectPlatform(p = process.platform) {
|
|
18
|
+
if (p === "darwin")
|
|
19
|
+
return "macos";
|
|
20
|
+
if (p === "win32")
|
|
21
|
+
return "windows";
|
|
22
|
+
return "linux";
|
|
23
|
+
}
|
|
24
|
+
/** The build the catalog offers for a platform, or null when there is none. */
|
|
25
|
+
export function pickBuild(clients, platform) {
|
|
26
|
+
return clients.find((c) => c.platform.toLowerCase() === platform && c.kind === "download") ?? null;
|
|
27
|
+
}
|
|
28
|
+
/** A stable per-build install directory name derived from the download path. */
|
|
29
|
+
export function buildKey(client) {
|
|
30
|
+
const m = /\/download\/([^/]+)\//.exec(client.url);
|
|
31
|
+
if (m?.[1])
|
|
32
|
+
return m[1];
|
|
33
|
+
const base = client.filename ?? path.basename(client.url);
|
|
34
|
+
return base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.(zip|tgz|tar\.gz|tar\.xz|dmg)$/i, "");
|
|
35
|
+
}
|
|
36
|
+
const MARKER = "installed.json";
|
|
37
|
+
/**
|
|
38
|
+
* Locate the launch target inside an unpacked tree. Mirrors the platform's
|
|
39
|
+
* `_ENTRYPOINT_CANDIDATES`, plus the .app bundle on macOS since `open`
|
|
40
|
+
* wants the bundle, not the Mach-O inside it.
|
|
41
|
+
*/
|
|
42
|
+
export function findEntrypoint(dir, platform) {
|
|
43
|
+
const walk = (root, depth) => {
|
|
44
|
+
if (depth < 0)
|
|
45
|
+
return [];
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
const full = path.join(root, e.name);
|
|
56
|
+
out.push(full);
|
|
57
|
+
if (e.isDirectory() && !e.name.endsWith(".app"))
|
|
58
|
+
out.push(...walk(full, depth - 1));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const all = walk(dir, 2);
|
|
63
|
+
const byName = (names) => {
|
|
64
|
+
for (const n of names) {
|
|
65
|
+
const hit = all.find((p) => path.basename(p).toLowerCase() === n);
|
|
66
|
+
if (hit)
|
|
67
|
+
return hit;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
if (platform === "macos") {
|
|
72
|
+
const app = all.find((p) => p.endsWith(".app") && fs.statSync(p).isDirectory());
|
|
73
|
+
return app ?? null;
|
|
74
|
+
}
|
|
75
|
+
if (platform === "windows")
|
|
76
|
+
return byName(["atomontage client.exe", "client.exe", "studio.exe"]);
|
|
77
|
+
return byName(["run.sh", "client", "studio"]);
|
|
78
|
+
}
|
|
79
|
+
export function loadInstalled(dir) {
|
|
80
|
+
try {
|
|
81
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, MARKER), "utf8"));
|
|
82
|
+
if (raw.entrypoint && fs.existsSync(raw.entrypoint))
|
|
83
|
+
return { ...raw, dir };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* not installed */
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
async function download(url, dest, onProgress, fetchFn = fetch) {
|
|
91
|
+
const res = await fetchFn(url);
|
|
92
|
+
if (!res.ok || !res.body)
|
|
93
|
+
throw new Error(`Download failed: HTTP ${res.status} for ${url}`);
|
|
94
|
+
const total = Number(res.headers.get("content-length")) || null;
|
|
95
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
96
|
+
const tmp = `${dest}.part`;
|
|
97
|
+
const out = fs.createWriteStream(tmp);
|
|
98
|
+
let received = 0;
|
|
99
|
+
const reader = res.body.getReader();
|
|
100
|
+
try {
|
|
101
|
+
for (;;) {
|
|
102
|
+
const { done, value } = await reader.read();
|
|
103
|
+
if (done)
|
|
104
|
+
break;
|
|
105
|
+
received += value.byteLength;
|
|
106
|
+
if (!out.write(value))
|
|
107
|
+
await new Promise((r) => out.once("drain", () => r()));
|
|
108
|
+
onProgress(received, total);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await new Promise((resolve, reject) => {
|
|
113
|
+
out.end(() => resolve());
|
|
114
|
+
out.on("error", reject);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
fs.renameSync(tmp, dest);
|
|
118
|
+
}
|
|
119
|
+
/** Unpack with the OS's own tools: ditto keeps bundle symlinks + bits on macOS; bsdtar handles zip + tgz elsewhere. */
|
|
120
|
+
export function extractArchive(archive, dest, platform) {
|
|
121
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
122
|
+
const lower = archive.toLowerCase();
|
|
123
|
+
let cmd;
|
|
124
|
+
let args;
|
|
125
|
+
if (platform === "macos" && lower.endsWith(".zip")) {
|
|
126
|
+
cmd = "ditto";
|
|
127
|
+
args = ["-x", "-k", archive, dest];
|
|
128
|
+
}
|
|
129
|
+
else if (lower.endsWith(".zip")) {
|
|
130
|
+
cmd = "tar";
|
|
131
|
+
args = ["-xf", archive, "-C", dest];
|
|
132
|
+
}
|
|
133
|
+
else if (/\.(tgz|tar\.gz|tar\.xz|tar\.bz2|tar)$/.test(lower)) {
|
|
134
|
+
cmd = "tar";
|
|
135
|
+
args = ["-xf", archive, "-C", dest];
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
throw new Error(`Don't know how to unpack ${path.basename(archive)}.`);
|
|
139
|
+
}
|
|
140
|
+
const res = spawnSync(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
141
|
+
if (res.error)
|
|
142
|
+
throw new Error(`Could not run ${cmd}: ${res.error.message}`);
|
|
143
|
+
if (res.status !== 0) {
|
|
144
|
+
throw new Error(`${cmd} failed (${res.status}): ${res.stderr?.toString().trim()}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function formatMb(bytes) {
|
|
148
|
+
return `${(bytes / 1048576).toFixed(0)} MB`;
|
|
149
|
+
}
|
|
150
|
+
/** Download + unpack the right build unless it is already here. */
|
|
151
|
+
export async function ensureClientInstalled(opts = {}) {
|
|
152
|
+
const platform = opts.platform ?? detectPlatform();
|
|
153
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
154
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
155
|
+
const catalog = await fetchNativeClientCatalog(opts.framingId, fetchFn);
|
|
156
|
+
const build = pickBuild(catalog.clients, platform);
|
|
157
|
+
if (!build) {
|
|
158
|
+
const others = catalog.clients.map((c) => `${c.platform} (${c.kind})`).join(", ");
|
|
159
|
+
throw new Error(`No native client build is published for ${platform} yet. Available: ${others || "none"}.`);
|
|
160
|
+
}
|
|
161
|
+
const dir = path.join(clientInstallRoot(), buildKey(build));
|
|
162
|
+
const existing = opts.force ? null : loadInstalled(dir);
|
|
163
|
+
if (existing)
|
|
164
|
+
return existing;
|
|
165
|
+
const url = build.url.startsWith("http") ? build.url : `${apiBaseFor(build)}${build.url}`;
|
|
166
|
+
const archive = path.join(clientInstallRoot(), build.filename ?? path.basename(build.url));
|
|
167
|
+
log(`Downloading the Virtual Matter client for ${platform}${build.file_size ? ` (${formatMb(build.file_size)})` : ""} ...`);
|
|
168
|
+
let lastPct = -1;
|
|
169
|
+
await download(url, archive, (received, total) => {
|
|
170
|
+
if (!total)
|
|
171
|
+
return;
|
|
172
|
+
const pct = Math.floor((received / total) * 100);
|
|
173
|
+
if (pct !== lastPct && (pct % 10 === 0 || pct === 100)) {
|
|
174
|
+
lastPct = pct;
|
|
175
|
+
log(` ${pct}%`);
|
|
176
|
+
}
|
|
177
|
+
}, fetchFn);
|
|
178
|
+
log(`Unpacking into ${dir} ...`);
|
|
179
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
extractArchive(archive, dir, platform);
|
|
181
|
+
fs.rmSync(archive, { force: true });
|
|
182
|
+
const entrypoint = findEntrypoint(dir, platform);
|
|
183
|
+
if (!entrypoint)
|
|
184
|
+
throw new Error(`Unpacked ${build.filename ?? "the build"} but found nothing to launch in ${dir}.`);
|
|
185
|
+
if (platform !== "windows") {
|
|
186
|
+
// Archives produced on Windows CI can lose the executable bit.
|
|
187
|
+
for (const p of [entrypoint, path.join(dir, "Client"), path.join(dir, "crashpad_handler")]) {
|
|
188
|
+
try {
|
|
189
|
+
if (fs.statSync(p).isFile())
|
|
190
|
+
fs.chmodSync(p, 0o755);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* absent */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const installed = { platform, dir, entrypoint, build };
|
|
198
|
+
fs.writeFileSync(path.join(dir, MARKER), JSON.stringify(installed, null, 2) + "\n");
|
|
199
|
+
return installed;
|
|
200
|
+
}
|
|
201
|
+
function apiBaseFor(_build) {
|
|
202
|
+
// Download paths are relative to the platform origin the catalog came from.
|
|
203
|
+
return (process.env.VIRTUALMATTER_API_BASE ?? "https://make.virtualmatter.ai").replace(/\/+$/, "");
|
|
204
|
+
}
|
|
205
|
+
/** Launch the client into a world, detached from this process. */
|
|
206
|
+
export function launchClient(installed, montageUrl) {
|
|
207
|
+
const arg = `--montage-url=${montageUrl}`;
|
|
208
|
+
let cmd;
|
|
209
|
+
let args;
|
|
210
|
+
if (installed.platform === "macos") {
|
|
211
|
+
cmd = "open";
|
|
212
|
+
args = ["-n", "-a", installed.entrypoint, "--args", arg];
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
cmd = installed.entrypoint;
|
|
216
|
+
args = [arg];
|
|
217
|
+
}
|
|
218
|
+
const child = spawn(cmd, args, {
|
|
219
|
+
cwd: installed.platform === "macos" ? undefined : path.dirname(installed.entrypoint),
|
|
220
|
+
detached: true,
|
|
221
|
+
stdio: "ignore",
|
|
222
|
+
windowsHide: false,
|
|
223
|
+
});
|
|
224
|
+
child.on("error", (err) => {
|
|
225
|
+
console.error(`Could not launch the client: ${err.message}`);
|
|
226
|
+
});
|
|
227
|
+
child.unref();
|
|
228
|
+
}
|
|
229
|
+
// ---------------------------------------------------------------- sign-in hand-over
|
|
230
|
+
/**
|
|
231
|
+
* Where the client keeps its sign-in file. Installer builds use the
|
|
232
|
+
* per-user app-data folder (SDL_GetPrefPath("Atomontage", "Atomontage
|
|
233
|
+
* Studio")); portable builds keep UserData next to the executable. Both
|
|
234
|
+
* are seeded so whichever the build reads, it finds a session.
|
|
235
|
+
*/
|
|
236
|
+
export function nativeUserDataDirs(installed, platform, home = os.homedir()) {
|
|
237
|
+
const dirs = [];
|
|
238
|
+
if (platform === "macos") {
|
|
239
|
+
dirs.push(path.join(home, "Library", "Application Support", "Atomontage", "Atomontage Studio", "UserData"));
|
|
240
|
+
}
|
|
241
|
+
else if (platform === "windows") {
|
|
242
|
+
const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
243
|
+
dirs.push(path.join(appData, "Atomontage", "Atomontage Studio", "UserData"));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const dataHome = process.env.XDG_DATA_HOME ?? path.join(home, ".local", "share");
|
|
247
|
+
dirs.push(path.join(dataHome, "Atomontage", "Atomontage Studio", "UserData"));
|
|
248
|
+
}
|
|
249
|
+
if (installed && platform !== "macos")
|
|
250
|
+
dirs.push(path.join(path.dirname(installed.entrypoint), "UserData"));
|
|
251
|
+
return dirs;
|
|
252
|
+
}
|
|
253
|
+
function decodeJwtClaims(token) {
|
|
254
|
+
try {
|
|
255
|
+
const payload = token.split(".")[1] ?? "";
|
|
256
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** The session file shape `vm_auth.py` (in the engine SDK) reads and refreshes. */
|
|
263
|
+
export function nativeAuthSession(creds) {
|
|
264
|
+
const claims = decodeJwtClaims(creds.access_token);
|
|
265
|
+
const disco = new URL(oidcDiscoveryUrl());
|
|
266
|
+
const m = /^(.*)\/realms\/([^/]+)\//.exec(disco.pathname);
|
|
267
|
+
const authBase = `${disco.origin}${m?.[1] ?? "/auth"}`;
|
|
268
|
+
const realm = m?.[2] ?? "Atomontage";
|
|
269
|
+
return {
|
|
270
|
+
access_token: creds.access_token,
|
|
271
|
+
refresh_token: creds.refresh_token ?? "",
|
|
272
|
+
id_token: "",
|
|
273
|
+
access_expires_at: Math.floor(creds.expires_at / 1000),
|
|
274
|
+
// 0 = "no known expiry": an offline refresh token from the CLI's
|
|
275
|
+
// device flow does not carry one, and a stamped-but-past value would
|
|
276
|
+
// read as expired.
|
|
277
|
+
refresh_expires_at: 0,
|
|
278
|
+
auth_base: authBase,
|
|
279
|
+
realm,
|
|
280
|
+
client_id: OAUTH_CLIENT_ID,
|
|
281
|
+
user: claims.preferred_username || claims.email || claims.sub || "",
|
|
282
|
+
email: claims.email || "",
|
|
283
|
+
sub: claims.sub || "",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Sign the native client in with the CLI's session. Skipped when the
|
|
288
|
+
* client already holds a session for the same account, so a sign-in done
|
|
289
|
+
* inside the app is never clobbered. Returns the files written.
|
|
290
|
+
*/
|
|
291
|
+
export function seedNativeAuth(installed, platform = detectPlatform()) {
|
|
292
|
+
const creds = loadCredentials();
|
|
293
|
+
if (!creds?.refresh_token)
|
|
294
|
+
return [];
|
|
295
|
+
const session = nativeAuthSession(creds);
|
|
296
|
+
const written = [];
|
|
297
|
+
for (const dir of nativeUserDataDirs(installed, platform)) {
|
|
298
|
+
const file = path.join(dir, "vm_auth.json");
|
|
299
|
+
try {
|
|
300
|
+
const existing = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
301
|
+
if (existing.refresh_token && existing.sub === session.sub)
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
/* absent or unreadable: write */
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
309
|
+
fs.writeFileSync(file, JSON.stringify(session) + "\n", { mode: 0o600 });
|
|
310
|
+
written.push(file);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* a read-only location is not worth failing the launch over */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return written;
|
|
317
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
/** Platform API base. Overridable so dev/staging environments work. */
|
|
4
6
|
export function apiBase() {
|
|
5
7
|
const raw = process.env.VIRTUALMATTER_API_BASE ?? "https://make.virtualmatter.ai";
|
|
@@ -18,5 +20,26 @@ export function configDir() {
|
|
|
18
20
|
export function credentialsPath() {
|
|
19
21
|
return path.join(configDir(), "credentials.json");
|
|
20
22
|
}
|
|
23
|
+
/** Where downloaded native client builds are unpacked: <configDir>/client/<build>. */
|
|
24
|
+
export function clientInstallRoot() {
|
|
25
|
+
return process.env.VIRTUALMATTER_CLIENT_DIR ?? path.join(configDir(), "client");
|
|
26
|
+
}
|
|
21
27
|
/** Name of the per-directory sync state file. */
|
|
22
28
|
export const STATE_FILE = ".virtualmatter.json";
|
|
29
|
+
/** The package version, read from package.json so it can never drift from npm. */
|
|
30
|
+
export function packageVersion() {
|
|
31
|
+
try {
|
|
32
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
for (const candidate of [path.join(here, "..", "package.json"), path.join(here, "..", "..", "package.json")]) {
|
|
34
|
+
if (fs.existsSync(candidate)) {
|
|
35
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
36
|
+
if (pkg.name === "virtualmatter" && typeof pkg.version === "string")
|
|
37
|
+
return pkg.version;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* fall through */
|
|
43
|
+
}
|
|
44
|
+
return "0.0.0";
|
|
45
|
+
}
|
package/dist/files.js
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
3
3
|
* https://<voxel_host>/s/<session_id>/api/montage/<framing_id>/...
|
|
4
4
|
*/
|
|
5
5
|
import { authorizedFetch } from "./api.js";
|
|
6
|
+
/**
|
|
7
|
+
* The reason an engine call failed, read out of the response body.
|
|
8
|
+
*
|
|
9
|
+
* The session API answers a refusal with
|
|
10
|
+
* `{detail: {error, message}}`, and dropping it left callers staring at a
|
|
11
|
+
* bare "HTTP 502" - which is exactly what sent test agents off to drive a
|
|
12
|
+
* browser instead. Falls back to the status when the body says nothing.
|
|
13
|
+
*/
|
|
14
|
+
export async function engineErrorMessage(res, what) {
|
|
15
|
+
let detail = "";
|
|
16
|
+
try {
|
|
17
|
+
const body = (await res.json());
|
|
18
|
+
const d = body.detail;
|
|
19
|
+
if (typeof d === "string")
|
|
20
|
+
detail = d;
|
|
21
|
+
else if (d && typeof d === "object" && typeof d.message === "string") {
|
|
22
|
+
detail = d.message;
|
|
23
|
+
}
|
|
24
|
+
else if (d)
|
|
25
|
+
detail = JSON.stringify(d);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
/* non-JSON body */
|
|
29
|
+
}
|
|
30
|
+
return `${what} failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`;
|
|
31
|
+
}
|
|
6
32
|
export class ConflictError extends Error {
|
|
7
33
|
filePath;
|
|
8
34
|
constructor(filePath) {
|
|
@@ -93,7 +119,7 @@ export class FilesClient {
|
|
|
93
119
|
body: JSON.stringify({ code, target }),
|
|
94
120
|
}, this.fetchFn);
|
|
95
121
|
if (!res.ok)
|
|
96
|
-
throw new Error(
|
|
122
|
+
throw new Error(await engineErrorMessage(res, "run-lua"));
|
|
97
123
|
// Server envelope: {output: <text>} - the engine bridge's text
|
|
98
124
|
// (result/print/error sections) passed through verbatim.
|
|
99
125
|
const body = (await res.json());
|
|
@@ -102,14 +128,36 @@ export class FilesClient {
|
|
|
102
128
|
async engineErrors() {
|
|
103
129
|
const res = await authorizedFetch(`${this.apiRoot}/engine/errors`, {}, this.fetchFn);
|
|
104
130
|
if (!res.ok)
|
|
105
|
-
throw new Error(
|
|
131
|
+
throw new Error(await engineErrorMessage(res, "Fetching engine errors"));
|
|
106
132
|
const body = (await res.json());
|
|
107
133
|
return body.output;
|
|
108
134
|
}
|
|
109
|
-
async screenshot() {
|
|
110
|
-
const
|
|
135
|
+
async screenshot(opts = {}) {
|
|
136
|
+
const body = {};
|
|
137
|
+
if (opts.target) {
|
|
138
|
+
body.target_object_id = opts.target;
|
|
139
|
+
if (opts.distance !== undefined)
|
|
140
|
+
body.distance = opts.distance;
|
|
141
|
+
}
|
|
142
|
+
else if (opts.at || opts.rot) {
|
|
143
|
+
const [px, py, pz] = opts.at ?? [0, 20, 20];
|
|
144
|
+
const [rx, ry, rz] = opts.rot ?? [0, -45, 0];
|
|
145
|
+
Object.assign(body, {
|
|
146
|
+
camera_pos_x: px,
|
|
147
|
+
camera_pos_y: py,
|
|
148
|
+
camera_pos_z: pz,
|
|
149
|
+
camera_rot_x: rx,
|
|
150
|
+
camera_rot_y: ry,
|
|
151
|
+
camera_rot_z: rz,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const res = await authorizedFetch(`${this.apiRoot}/engine/screenshot`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: JSON.stringify(body),
|
|
158
|
+
}, this.fetchFn);
|
|
111
159
|
if (!res.ok)
|
|
112
|
-
throw new Error(
|
|
160
|
+
throw new Error(await engineErrorMessage(res, "Screenshot"));
|
|
113
161
|
return Buffer.from(await res.arrayBuffer());
|
|
114
162
|
}
|
|
115
163
|
}
|
package/dist/ignore.js
CHANGED
|
@@ -15,9 +15,30 @@ export function isIgnoredPath(relPath) {
|
|
|
15
15
|
return true;
|
|
16
16
|
if (norm === STATE_FILE)
|
|
17
17
|
return true;
|
|
18
|
-
|
|
18
|
+
// Harness files the CLI drops into a pulled folder; they belong to the
|
|
19
|
+
// maker's machine, not the world.
|
|
20
|
+
if (norm === "AGENTS.md" || norm === "CLAUDE.md")
|
|
19
21
|
return true;
|
|
20
22
|
if (norm.endsWith(".remote-conflict"))
|
|
21
23
|
return true;
|
|
24
|
+
// The SDK's in-session harness tooling at the Montage root. `atomo` only
|
|
25
|
+
// works against a running engine bridge, so mirroring it invites an agent
|
|
26
|
+
// to try it and fail; the CLI is the local path. Sync leaves them alone in
|
|
27
|
+
// both directions.
|
|
28
|
+
if (segments.length === 1 && isSdkHarnessTooling(first))
|
|
29
|
+
return true;
|
|
22
30
|
return false;
|
|
23
31
|
}
|
|
32
|
+
const SDK_HARNESS_FILES = new Set([
|
|
33
|
+
"atomo",
|
|
34
|
+
"atomo.bat",
|
|
35
|
+
"atomo_cli.py",
|
|
36
|
+
"vm_auth.py",
|
|
37
|
+
"agent_log_sync.py",
|
|
38
|
+
"atomontage.code-workspace",
|
|
39
|
+
]);
|
|
40
|
+
export function isSdkHarnessTooling(name) {
|
|
41
|
+
if (SDK_HARNESS_FILES.has(name))
|
|
42
|
+
return true;
|
|
43
|
+
return /_agent_log_(sync|setup)\.py$/.test(name);
|
|
44
|
+
}
|