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 CHANGED
@@ -1,8 +1,9 @@
1
1
  # virtualmatter
2
2
 
3
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.
4
+ gives coding agents direct hands on your worlds: listing and creating them,
5
+ their live Montage files, Lua execution, engine errors, screenshots, and the
6
+ native desktop client.
6
7
 
7
8
  No install needed:
8
9
 
@@ -16,76 +17,143 @@ Requires Node 20 or newer. No native modules.
16
17
 
17
18
  ## Quickstart
18
19
 
19
- ```bash
20
- # 1. Sign in (device code - approve it in your browser)
21
- npx virtualmatter login
20
+ One command. It signs you in on first use (device code, approved in your
21
+ browser), creates the world, and mirrors its files into `./my-world`:
22
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
23
+ ```bash
24
+ npx virtualmatter create "My world"
25
25
  cd my-world
26
-
27
- # 3. Start the hot loop: local edits push instantly, remote edits pull back.
28
26
  npx virtualmatter sync
29
27
  ```
30
28
 
29
+ Already have a world? Paste any link to it - `/edit`, `/play`, `/g`, `/p`,
30
+ `/projects`, with or without the readable name in the URL:
31
+
32
+ ```bash
33
+ npx virtualmatter list
34
+ npx virtualmatter pull https://make.virtualmatter.ai/edit/my-world-<framing-id>
35
+ ```
36
+
31
37
  While `sync` runs, saving a Lua script in your editor deploys it - scripts
32
38
  hot-reload in the engine. If someone edits the same file in the live session,
33
39
  your copy stays put and the remote version lands next to it as
34
40
  `<name>.remote-conflict` for you to merge.
35
41
 
42
+ Want the native client instead of the browser? This downloads it on first
43
+ use, signs it in with your account, and opens the world:
44
+
45
+ ```bash
46
+ npx virtualmatter open
47
+ ```
48
+
36
49
  Poke at the running engine from another terminal:
37
50
 
38
51
  ```bash
39
52
  npx virtualmatter run-lua --code "return Server.GetInfo()"
40
53
  npx virtualmatter errors
41
- npx virtualmatter screenshot -o shot.png
54
+ npx virtualmatter screenshot -o shot.png # default overview
55
+ npx virtualmatter screenshot --target "Player" -o p.png # frame one object
56
+ npx virtualmatter screenshot --at 0,30,40 --rot 0,-35,0 # exact camera
57
+ # --rot is yaw,pitch,roll
42
58
  ```
43
59
 
44
- ## Hook up a coding agent (MCP)
60
+ Work through these commands rather than driving make.virtualmatter.ai in a
61
+ browser. The built-in agent on that site runs on Virtual Matter's platform
62
+ credits instead of your own subscription, and an anonymous browser session
63
+ cannot be steered after its first turn.
45
64
 
46
- Register the MCP server with Claude Code in one line, from a pulled folder:
65
+ ## Hook up a coding agent (MCP)
47
66
 
48
- ```bash
49
- claude mcp add virtualmatter -- npx -y virtualmatter mcp
50
- ```
67
+ A folder created by `create` or `pull` already carries `.mcp.json` (Claude
68
+ Code), `.cursor/mcp.json` (Cursor), `AGENTS.md` (Codex, Cursor, and most
69
+ agents), and a `CLAUDE.md` that imports it - open the folder in your agent
70
+ and it finds the MCP server and the project briefing on its own.
51
71
 
52
- Or point it at a framing directly, no pulled folder needed:
72
+ To register the server by hand, from anywhere:
53
73
 
54
74
  ```bash
55
- claude mcp add virtualmatter -- npx -y virtualmatter mcp --framing <framing-id>
75
+ claude mcp add virtualmatter -- npx -y virtualmatter mcp
56
76
  ```
57
77
 
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.
78
+ It works before a world is selected: `list_projects`, `create_project`, and
79
+ `select_project` (any link or id, optionally mirroring into a folder) pick
80
+ one, then `list_files`, `read_file`, `write_file`, `run_lua`,
81
+ `get_engine_errors`, `capture_screenshot`, `open_native_client`, and
82
+ `world_info` act on it. Writes handle etag concurrency internally, and
83
+ written Lua hot-reloads in the engine - so for an agent, writing a script is
84
+ deploying it.
62
85
 
63
86
  ## Commands
64
87
 
65
88
  | Command | What it does |
66
89
  | --- | --- |
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. |
90
+ | `create <name> [dir]` | Create a world (region defaults to the nearest; `--region`, `--track`, `--description`, `--no-pull`, `--open`, `--json`) and mirror it into `./<slug>`. |
91
+ | `list` (`ls`, `projects`) | Your worlds with framing ids and edit/play URLs (`--json`). |
92
+ | `pull [target] [dir]` | Mirror a world's file tree. `target` is any Virtual Matter link or a framing id; omitted, it uses this folder's world or your only one. |
69
93
  | `sync [dir]` | Watch + two-way sync with the live session. Ctrl-C to stop. |
94
+ | `open [target]` (`client`) | Open a world in the native desktop client, downloading and signing it in on first use. `--install-only`, `--print` (just the download URL), `--force`. |
70
95
  | `run-lua [dir] --code "<lua>" [--target server\|client]` | Execute Lua in the running engine. |
71
96
  | `errors [dir]` | Recent engine errors. |
72
97
  | `screenshot [dir] [-o out.png]` | Capture the engine's current view. |
73
- | `client` | Print the native client download link for your OS. |
74
98
  | `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.
99
+ | `login` / `logout` / `whoami` | Device-code sign-in; every other command signs you in automatically when needed. Tokens live in `~/.config/virtualmatter/credentials.json` (mode 0600) and refresh automatically. |
80
100
 
81
101
  ## Good to know
82
102
 
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
103
  - `VIRTUALMATTER_API_BASE` overrides the platform API base (default
87
104
  `https://make.virtualmatter.ai`) for dev/staging environments.
88
- - Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, and its own
89
- bookkeeping (`.virtualmatter.json`).
105
+ - `VIRTUALMATTER_NO_AUTO_LOGIN=1` turns the automatic first-use sign-in into
106
+ an error, for scripts that must never block on a browser.
107
+ - The native client is unpacked under `~/.config/virtualmatter/client/<build>`
108
+ (override with `VIRTUALMATTER_CLIENT_DIR`); each engine build gets its own
109
+ folder, so a newer build never overwrites the one you are running.
110
+ - Sync skips `Uploads/`, `Screenshots/`, `Agent Logs/`, dotfiles, the
111
+ harness files the CLI writes (`AGENTS.md`, `CLAUDE.md`, `.mcp.json`,
112
+ `.cursor/`, `.virtualmatter.json`), and the SDK's in-session tooling at the
113
+ Montage root (`atomo`, `vm_auth.py`, the agent-log hooks) - those only work
114
+ inside a running session.
115
+ - The pulled `AGENTS.md` is the platform guide followed by the world's own
116
+ engine SDK guide (its Skills/ table and engine rules), with a header that
117
+ maps every `atomo` command the engine guide mentions to the CLI or MCP
118
+ equivalent.
119
+ - Linux: the client is a tarball started through `run.sh`; `open` does that
120
+ for you. If no window appears, the newest log under
121
+ `~/.local/share/Atomontage/Atomontage Studio/UserData/Logs/` says why, and
122
+ the world keeps working in the browser and through every CLI command.
123
+ - Sandboxed agents (Codex, restricted Claude Code) need network access for
124
+ every command: the platform, the identity server, and the world's host.
90
125
  - `pull` may cold-start a session for the framing; the first one can take a
91
126
  minute.
127
+
128
+ ## Websites and Lovable
129
+
130
+ For a shared world, no account or local engine is needed:
131
+
132
+ ```bash
133
+ npx virtualmatter embed 'https://play.virtualmatter.ai/p/world-a1b2c3d4'
134
+ ```
135
+
136
+ The response contains canonical iframe markup, required **host response
137
+ headers**, and verification instructions. Installing this CLI alone cannot
138
+ configure the website's HTTP headers. Read `/embed-guide.md` on the platform.
139
+
140
+ To ask VM's own agent to build a new world (using your VM credits):
141
+
142
+ ```bash
143
+ npx virtualmatter build 'Lava Arena' --prompt 'Build a multiplayer lava survival arena' --request-id lava-arena-001
144
+ npx virtualmatter build-status <build-id>
145
+ ```
146
+
147
+ Reuse the same request id if the network drops; changing it requests another
148
+ world. Review the world and enable **Share with friends** before embedding.
149
+ An uncertain delivery is never automatically resubmitted.
150
+
151
+ The local MCP `create_project` also accepts `prompt` plus `request_id`, and
152
+ exposes `get_embed` and `get_build_status`. Without a prompt it retains the
153
+ existing empty-world creation behavior.
154
+
155
+ In Lovable, add `https://make.virtualmatter.ai/api/v1/mcp` as a custom MCP
156
+ server with OAuth. Sign in or create a VM account, approve the connection,
157
+ and return to Lovable. Then ask “Embed my Lava Arena world” or “Create a
158
+ Virtual Matter lava survival arena and embed it here.” This remote endpoint
159
+ runs on the platform; users do not install or run this CLI for that flow.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The files that make a pulled folder self-describing to whichever harness
3
+ * opens it: AGENTS.md (Codex, Cursor, and most others read it), CLAUDE.md
4
+ * (Claude Code reads this one and imports AGENTS.md through it), and the
5
+ * MCP registrations Claude Code (`.mcp.json`) and Cursor (`.cursor/mcp.json`)
6
+ * discover on their own. Existing files are never overwritten - a maker's
7
+ * own notes win - except AGENTS.md, which the CLI owns.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { apiBase } from "./config.js";
12
+ export const AGENTS_MD_STUB = `# Working with Virtual Matter
13
+
14
+ This folder is a live mirror of a Virtual Matter world's Montage files.
15
+
16
+ - \`npx virtualmatter sync\` keeps it in sync with the running session.
17
+ - \`npx virtualmatter run-lua --code "..."\` executes Lua in the engine.
18
+ - \`npx virtualmatter errors\` shows recent engine errors.
19
+ - \`npx virtualmatter screenshot -o shot.png\` captures the current view.
20
+ - \`npx virtualmatter open\` opens this world in the native desktop client.
21
+ - Lua scripts under this tree hot-reload in the engine when saved.
22
+
23
+ Do not edit \`.virtualmatter.json\` - it is sync bookkeeping.
24
+ `;
25
+ export async function fetchAgentsMd(fetchFn = fetch) {
26
+ try {
27
+ const res = await fetchFn(`${apiBase()}/AGENTS.md`);
28
+ if (res.ok) {
29
+ const text = await res.text();
30
+ if (text.trim().length > 0 && !text.trimStart().startsWith("<"))
31
+ return text;
32
+ }
33
+ }
34
+ catch {
35
+ /* fall through to the stub */
36
+ }
37
+ return AGENTS_MD_STUB;
38
+ }
39
+ const CLAUDE_MD = `@AGENTS.md
40
+
41
+ This folder mirrors a live Virtual Matter world. Keep \`npx virtualmatter sync\`
42
+ running while you edit so saves hot-reload in the running world, or use the
43
+ \`virtualmatter\` MCP server registered in \`.mcp.json\` to read, write, run Lua,
44
+ check engine errors, and take screenshots directly.
45
+ `;
46
+ function mcpServerConfig() {
47
+ return {
48
+ command: "npx",
49
+ args: ["-y", "virtualmatter", "mcp"],
50
+ };
51
+ }
52
+ /**
53
+ * The SDK's own AGENTS.md ships inside every Montage tree. It is written for
54
+ * the in-session agent that drives the engine through `atomo`, which only
55
+ * works against a running engine bridge - so a local harness must not follow
56
+ * it literally. Its Skills/ table and engine rules are still the best
57
+ * reference there is, so the pulled AGENTS.md carries BOTH: the platform
58
+ * guide first, then the engine doc under a header that translates every
59
+ * `atomo` step into its CLI equivalent.
60
+ */
61
+ export function composeAgentsMd(platformDoc, engineDoc) {
62
+ if (!engineDoc || engineDoc.trim().length === 0)
63
+ return platformDoc;
64
+ const bridge = `
65
+
66
+ ---
67
+
68
+ # Engine reference (the world's SDK AGENTS.md)
69
+
70
+ The section below is the engine SDK's own agent guide, mirrored from this
71
+ world's Montage tree. It assumes an agent running INSIDE a Virtual Matter
72
+ session, where an \`atomo\` command talks to the live engine. Here, on your own
73
+ machine, there is no engine bridge - use the \`virtualmatter\` CLI (or its MCP
74
+ tools) wherever the text says \`atomo\`:
75
+
76
+ | Engine doc says | Do this instead |
77
+ | --- | --- |
78
+ | \`atomo run-lua '<code>'\` | \`npx virtualmatter run-lua --code '<code>'\` (MCP: \`run_lua\`) |
79
+ | \`atomo run-lua-client ...\` | \`npx virtualmatter run-lua --target client --code '<code>'\` |
80
+ | \`atomo errors\` | \`npx virtualmatter errors\` (MCP: \`get_engine_errors\`) |
81
+ | \`atomo screenshot\` / \`screenshot-at\` / \`screenshot-obj\` | \`npx virtualmatter screenshot -o shot.png\` (MCP: \`capture_screenshot\`); aim the camera with run-lua first if needed |
82
+ | \`atomo prints\` / \`atomo status\` | not available locally; use \`return\` values from run-lua |
83
+ | edit a .lua file, then check \`atomo errors\` | save the file with \`npx virtualmatter sync\` running (or MCP \`write_file\`), then check errors the same way |
84
+ | \`Skills/git-commit.md\` (git add/commit) | skip it - there is no git here; sync IS the persistence |
85
+ | paths under \`/data/persist/Montage/\` | this folder |
86
+
87
+ Everything else in it - the Skills/ table, the server/client rules, the Lua
88
+ API rules, the mandatory error check after every change - applies as written.
89
+ The \`Skills/\` files it references are in this folder.
90
+
91
+ `;
92
+ return platformDoc.trimEnd() + "\n" + bridge + engineDoc.trimStart();
93
+ }
94
+ /** Write the harness files into a pulled folder. */
95
+ export function writeAgentFiles(dir, agentsMd) {
96
+ const written = [];
97
+ const kept = [];
98
+ const put = (rel, content, overwrite) => {
99
+ const abs = path.join(dir, rel);
100
+ if (!overwrite && fs.existsSync(abs)) {
101
+ kept.push(rel);
102
+ return;
103
+ }
104
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
105
+ fs.writeFileSync(abs, content);
106
+ written.push(rel);
107
+ };
108
+ put("AGENTS.md", agentsMd, true);
109
+ put("CLAUDE.md", CLAUDE_MD, false);
110
+ const mcp = JSON.stringify({ mcpServers: { virtualmatter: mcpServerConfig() } }, null, 2) + "\n";
111
+ put(".mcp.json", mcp, false);
112
+ put(path.join(".cursor", "mcp.json"), mcp, false);
113
+ return { written, kept };
114
+ }
package/dist/api.js CHANGED
@@ -58,3 +58,117 @@ export async function fetchNativeClients(fetchFn = fetch) {
58
58
  const body = (await res.json());
59
59
  return body.clients ?? [];
60
60
  }
61
+ async function readError(res, what) {
62
+ let detail = "";
63
+ try {
64
+ const body = (await res.json());
65
+ if (typeof body.detail === "string")
66
+ detail = body.detail;
67
+ else if (body.detail)
68
+ detail = JSON.stringify(body.detail);
69
+ }
70
+ catch {
71
+ /* non-JSON */
72
+ }
73
+ return new Error(`${what} failed: HTTP ${res.status}${detail ? ` - ${detail}` : ""}`);
74
+ }
75
+ export async function listProjects(fetchFn = fetch) {
76
+ const res = await authorizedFetch(`${apiBase()}/api/v1/montages`, {}, fetchFn);
77
+ if (!res.ok)
78
+ throw await readError(res, "Listing projects");
79
+ return (await res.json());
80
+ }
81
+ export async function getProject(montageId, fetchFn = fetch) {
82
+ const res = await authorizedFetch(`${apiBase()}/api/v1/montages/${encodeURIComponent(montageId)}`, {}, fetchFn);
83
+ if (res.status === 404)
84
+ throw new Error(`Project ${montageId} was not found (or is not yours).`);
85
+ if (!res.ok)
86
+ throw await readError(res, "Loading the project");
87
+ return (await res.json());
88
+ }
89
+ export async function getProjectByPublicId(publicId, fetchFn = fetch) {
90
+ const res = await authorizedFetch(`${apiBase()}/api/v1/montages/by-public-id/${encodeURIComponent(publicId)}`, {}, fetchFn);
91
+ if (res.status === 404)
92
+ throw new Error(`Project ${publicId} was not found (or is not yours).`);
93
+ if (!res.ok)
94
+ throw await readError(res, "Resolving the project link");
95
+ const identity = (await res.json());
96
+ return getProject(identity.id, fetchFn);
97
+ }
98
+ export async function createProject(input, fetchFn = fetch) {
99
+ const res = await authorizedFetch(`${apiBase()}/api/v1/montages`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({
103
+ name: input.name,
104
+ description: input.description ?? null,
105
+ region: input.region,
106
+ engine_track: input.engine_track ?? "stable",
107
+ }),
108
+ }, fetchFn);
109
+ if (res.status === 409) {
110
+ throw new Error(`You already have a project named "${input.name}". Pick another name.`);
111
+ }
112
+ if (!res.ok)
113
+ throw await readError(res, "Creating the project");
114
+ return (await res.json());
115
+ }
116
+ /**
117
+ * The region a new project should live in when the caller did not say:
118
+ * the one closest to the machine's clock. Coarse on purpose - a wrong
119
+ * guess costs some latency, a prompt costs the whole zero-friction flow.
120
+ */
121
+ export function defaultRegion(now = new Date()) {
122
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
123
+ if (tz.startsWith("America/") || tz.startsWith("US/") || tz.startsWith("Canada/") || tz === "Pacific/Honolulu")
124
+ return "NA";
125
+ if (tz.startsWith("Asia/") || tz.startsWith("Australia/") || tz.startsWith("Pacific/"))
126
+ return "AS";
127
+ if (tz.startsWith("Europe/") || tz.startsWith("Africa/") || tz === "UTC")
128
+ return "EU";
129
+ // No zone name: fall back to the UTC offset (minutes WEST of UTC).
130
+ const offset = now.getTimezoneOffset();
131
+ if (offset >= 180)
132
+ return "NA";
133
+ if (offset <= -300)
134
+ return "AS";
135
+ return "EU";
136
+ }
137
+ // ---------------------------------------------------------------- urls
138
+ /** The readable editor link for a framing: `/edit/<slug>-<id>` or `/edit/<id>`. */
139
+ export function editUrl(framingId, slug) {
140
+ return `${apiBase()}/edit/${slug ? `${slug}-${framingId}` : framingId}`;
141
+ }
142
+ export function playUrl(framingId) {
143
+ return `${apiBase()}/play/${framingId}`;
144
+ }
145
+ /** The full catalog, version-matched to a framing's running engine when given. */
146
+ export async function fetchNativeClientCatalog(framingId, fetchFn = fetch) {
147
+ const q = framingId ? `?framing_id=${encodeURIComponent(framingId)}` : "";
148
+ const res = await fetchFn(`${apiBase()}/api/v1/public/native-clients${q}`);
149
+ if (!res.ok)
150
+ throw new Error(`GET /api/v1/public/native-clients failed: HTTP ${res.status}`);
151
+ const body = (await res.json());
152
+ return { iteration: body.iteration ?? null, clients: body.clients ?? [] };
153
+ }
154
+ /** Website-builder operations share the hosted integration's canonical contract. */
155
+ export async function getEmbed(target, fetchFn = fetch) {
156
+ const response = await fetchFn(`${apiBase()}/api/v1/public/embed?target=${encodeURIComponent(target)}`);
157
+ if (!response.ok)
158
+ throw await readError(response, "Resolving embed (the world must be shared)");
159
+ return await response.json();
160
+ }
161
+ export async function createWebsiteBuild(input, fetchFn = fetch) {
162
+ const response = await authorizedFetch(`${apiBase()}/api/v1/website-builds`, {
163
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input),
164
+ }, fetchFn);
165
+ if (!response.ok)
166
+ throw await readError(response, "Starting VM build");
167
+ return await response.json();
168
+ }
169
+ export async function getWebsiteBuild(buildId, fetchFn = fetch) {
170
+ const response = await authorizedFetch(`${apiBase()}/api/v1/website-builds/${encodeURIComponent(buildId)}`, {}, fetchFn);
171
+ if (!response.ok)
172
+ throw await readError(response, "Reading VM build status");
173
+ return await response.json();
174
+ }
package/dist/auth.js CHANGED
@@ -17,7 +17,7 @@ export class DeviceFlowDisabledError extends Error {
17
17
  }
18
18
  export class NotLoggedInError extends Error {
19
19
  constructor() {
20
- super("Not signed in. Run `virtualmatter login` first.");
20
+ super("Not signed in. Run `npx virtualmatter login` first.");
21
21
  this.name = "NotLoggedInError";
22
22
  }
23
23
  }
@@ -48,7 +48,13 @@ function form(params) {
48
48
  /** Start the device flow: returns the codes to show the user, plus the token endpoint. */
49
49
  export async function startDeviceFlow(fetchFn = fetch) {
50
50
  const disco = await fetchDiscovery(fetchFn);
51
- const res = await fetchFn(disco.device_authorization_endpoint, form({ client_id: OAUTH_CLIENT_ID, scope: "openid" }));
51
+ const res = await fetchFn(disco.device_authorization_endpoint,
52
+ // offline_access is what makes this a CLI session rather than a
53
+ // browser tab: without it the refresh token dies with the SSO
54
+ // session's idle timeout (~30 min) and users get signed out
55
+ // overnight. With it, Keycloak issues an offline refresh token
56
+ // (realm default: 30-day idle) that survives browser logout.
57
+ form({ client_id: OAUTH_CLIENT_ID, scope: "openid offline_access" }));
52
58
  if (!res.ok) {
53
59
  let body = {};
54
60
  try {
@@ -78,7 +84,7 @@ export async function pollForToken(tokenEndpoint, auth, opts = {}) {
78
84
  const deadline = now() + auth.expires_in * 1000;
79
85
  for (;;) {
80
86
  if (now() > deadline) {
81
- throw new Error("Device sign-in timed out before the code was approved. Run `virtualmatter login` again.");
87
+ throw new Error("Device sign-in timed out before the code was approved. Run `npx virtualmatter login` again.");
82
88
  }
83
89
  await sleep(intervalMs);
84
90
  const res = await fetchFn(tokenEndpoint, form({
@@ -109,7 +115,7 @@ export async function pollForToken(tokenEndpoint, auth, opts = {}) {
109
115
  intervalMs += 5000;
110
116
  continue;
111
117
  case "expired_token":
112
- throw new Error("The sign-in code expired before it was approved. Run `virtualmatter login` again.");
118
+ throw new Error("The sign-in code expired before it was approved. Run `npx virtualmatter login` again.");
113
119
  case "access_denied":
114
120
  throw new Error("Sign-in was denied.");
115
121
  case "unauthorized_client":