@solidrt/cli 0.0.27 → 0.0.29

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.
@@ -0,0 +1,94 @@
1
+ import { existsSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { source } from "../args"
4
+ import { bundleWith } from "../bundler"
5
+
6
+ // srt check: verify the app without side effects. Bundles in memory (nothing
7
+ // written, so no dev-server reload fires and no build outputs land in the
8
+ // project) and typechecks with the project's own tsc, reporting only
9
+ // diagnostics in app code. @solidrt packages ship raw .ts sources, so a strict
10
+ // consumer config surfaces their internal errors too; those are counted and
11
+ // hidden, not the caller's problem to wade through.
12
+
13
+ // Walk up from the entry to the enclosing project (tsconfig.json or, failing
14
+ // that, package.json).
15
+ function findProjectRoot(entry: string): string | null {
16
+ let dir = dirname(resolve(entry))
17
+ let byConfig: string | null = null
18
+ let byPackage: string | null = null
19
+ while (true) {
20
+ if (!byConfig && existsSync(join(dir, "tsconfig.json"))) byConfig = dir
21
+ if (!byPackage && existsSync(join(dir, "package.json"))) byPackage = dir
22
+ let parent = dirname(dir)
23
+ if (parent === dir) return byConfig ?? byPackage
24
+ dir = parent
25
+ }
26
+ }
27
+
28
+ // One tsc --pretty false diagnostic: the "path(line,col): error TS...: ..."
29
+ // head line plus any indented continuation lines.
30
+ type Diagnostic = { head: string; lines: string[]; inDependencies: boolean }
31
+
32
+ function parseDiagnostics(output: string): Diagnostic[] {
33
+ let diagnostics: Diagnostic[] = []
34
+ let current: Diagnostic | null = null
35
+ for (let line of output.split("\n")) {
36
+ let head = /^(.*?)\(\d+,\d+\): (error|warning) TS\d+: /.exec(line) ?? /^(error|warning) TS\d+: /.exec(line)
37
+ if (head) {
38
+ let file = line.includes("): ") ? head[1]! : ""
39
+ current = { head: line, lines: [line], inDependencies: file.includes("node_modules") }
40
+ diagnostics.push(current)
41
+ } else if (current && line.trim() !== "") {
42
+ current.lines.push(line)
43
+ }
44
+ }
45
+ return diagnostics
46
+ }
47
+
48
+ async function typecheck(root: string): Promise<{ app: Diagnostic[]; hidden: number } | null> {
49
+ let tsc = join(root, "node_modules", ".bin", process.platform === "win32" ? "tsc.exe" : "tsc")
50
+ if (!existsSync(tsc)) {
51
+ console.warn("Typecheck skipped: no tsc in the project (add the typescript devDependency)")
52
+ return null
53
+ }
54
+ let proc = Bun.spawn([tsc, "--noEmit", "--pretty", "false"], { cwd: root, stdout: "pipe", stderr: "pipe" })
55
+ let [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
56
+ await proc.exited
57
+ let diagnostics = parseDiagnostics(out + err)
58
+ let app = diagnostics.filter((d) => !d.inDependencies)
59
+ return { app, hidden: diagnostics.length - app.length }
60
+ }
61
+
62
+ export async function runCheckCommand() {
63
+ let entry = source!
64
+ let failed = false
65
+
66
+ let result = await bundleWith({ entry, dev: true, minify: false })
67
+ if (!result) {
68
+ // bundleWith already printed the compile errors.
69
+ failed = true
70
+ }
71
+
72
+ let root = findProjectRoot(entry)
73
+ if (!root) {
74
+ console.warn("Typecheck skipped: no tsconfig.json or package.json above the entry")
75
+ } else {
76
+ let types = await typecheck(root)
77
+ if (types) {
78
+ for (let d of types.app) console.error(d.lines.join("\n"))
79
+ if (types.app.length > 0) {
80
+ failed = true
81
+ let hidden = types.hidden > 0 ? ` (${types.hidden} in dependencies hidden)` : ""
82
+ console.error(`${types.app.length} type error${types.app.length === 1 ? "" : "s"} in app code${hidden}`)
83
+ } else if (types.hidden > 0) {
84
+ console.log(`Types OK (${types.hidden} dependency-internal errors hidden)`)
85
+ } else {
86
+ console.log("Types OK")
87
+ }
88
+ }
89
+ }
90
+
91
+ if (failed) process.exit(1)
92
+ console.log("Check passed")
93
+ process.exit(0)
94
+ }
@@ -29,23 +29,48 @@ function packageName(dir: string): string {
29
29
  }
30
30
 
31
31
  const DEFAULT_TEMPLATE = "default"
32
+ const TEMPLATE_MANIFEST = "template.json"
33
+
34
+ // Each template's template.json declares which level the scaffolded app is
35
+ // written at: "core" (only @solidrt/core, no component framework) or
36
+ // "components" (built with @solidrt/components). The level decides the
37
+ // generated dependencies; the description labels the template in the picker.
38
+ interface TemplateInfo {
39
+ name: string
40
+ level: "core" | "components"
41
+ description: string
42
+ }
32
43
 
33
44
  // Templates are the directories under scaffold/templates/; each holds the files
34
- // that become the new project's src/. `default` sorts first as the starting
35
- // point, the rest alphabetically.
36
- async function listTemplates(): Promise<string[]> {
45
+ // that become the new project's src/, plus a template.json manifest. `default`
46
+ // sorts first as the starting point, the rest alphabetically.
47
+ async function listTemplates(): Promise<TemplateInfo[]> {
37
48
  let entries = await readdir(TEMPLATES_DIR, { withFileTypes: true })
38
- return entries
49
+ let names = entries
39
50
  .filter((e) => e.isDirectory())
40
51
  .map((e) => e.name)
41
52
  .sort((a, b) =>
42
53
  a === DEFAULT_TEMPLATE ? -1 : b === DEFAULT_TEMPLATE ? 1 : a.localeCompare(b),
43
54
  )
55
+ let templates: TemplateInfo[] = []
56
+ for (let name of names) {
57
+ // A missing manifest falls back to the components level: it keeps every
58
+ // dependency, so the scaffolded app works at either level.
59
+ let manifest = await readFile(join(TEMPLATES_DIR, name, TEMPLATE_MANIFEST), "utf8")
60
+ .then((raw) => JSON.parse(raw))
61
+ .catch(() => ({}))
62
+ templates.push({
63
+ name,
64
+ level: manifest.level === "core" ? "core" : "components",
65
+ description: typeof manifest.description === "string" ? manifest.description : "",
66
+ })
67
+ }
68
+ return templates
44
69
  }
45
70
 
46
71
  // Resolve which template to scaffold from: an explicit --template if valid, an
47
72
  // interactive picker on a TTY, else `default` (or the first available).
48
- async function resolveTemplate(): Promise<string> {
73
+ async function resolveTemplate(): Promise<TemplateInfo> {
49
74
  let templates = await listTemplates()
50
75
  if (templates.length === 0) {
51
76
  console.error(`!! No templates found in ${TEMPLATES_DIR}`)
@@ -53,14 +78,25 @@ async function resolveTemplate(): Promise<string> {
53
78
  }
54
79
  let chosen = values.template
55
80
  if (chosen) {
56
- if (!templates.includes(chosen)) {
57
- console.error(`!! Unknown template "${chosen}"; choose from: ${templates.join(", ")}`)
81
+ let found = templates.find((t) => t.name === chosen)
82
+ if (!found) {
83
+ let names = templates.map((t) => t.name).join(", ")
84
+ console.error(`!! Unknown template "${chosen}"; choose from: ${names}`)
58
85
  process.exit(1)
59
86
  }
60
- return chosen
87
+ return found
88
+ }
89
+ if (process.stdin.isTTY) {
90
+ let picked = await select(
91
+ "Select a template",
92
+ templates.map((t) => ({
93
+ label: t.description ? `${t.name} - ${t.description}` : t.name,
94
+ value: t.name,
95
+ })),
96
+ )
97
+ return templates.find((t) => t.name === picked)!
61
98
  }
62
- if (process.stdin.isTTY) return select("Select a template", templates)
63
- return templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0]!
99
+ return templates.find((t) => t.name === DEFAULT_TEMPLATE) ?? templates[0]!
64
100
  }
65
101
 
66
102
  export async function runInitCommand() {
@@ -84,7 +120,7 @@ export async function runInitCommand() {
84
120
 
85
121
  let template = await resolveTemplate()
86
122
 
87
- console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template})`)
123
+ console.log(`>> Scaffolding SolidRT project in ${resolve(dir)} (${template.name})`)
88
124
  for (let { from, to } of TEMPLATE_FILES) {
89
125
  let dest = join(dir, to)
90
126
  await mkdir(dirname(dest), { recursive: true })
@@ -93,19 +129,22 @@ export async function runInitCommand() {
93
129
  }
94
130
 
95
131
  // The chosen template's files become the project's src/. Entries may be
96
- // nested directories (e.g. an asset folder), so copy recursively.
97
- let templateDir = join(TEMPLATES_DIR, template)
132
+ // nested directories (e.g. an asset folder), so copy recursively. The
133
+ // manifest describes the template rather than belonging to the app.
134
+ let templateDir = join(TEMPLATES_DIR, template.name)
98
135
  await mkdir(join(dir, "src"), { recursive: true })
99
136
  for (let file of await readdir(templateDir)) {
137
+ if (file === TEMPLATE_MANIFEST) continue
100
138
  await cp(join(templateDir, file), join(dir, "src", file), { recursive: true })
101
139
  console.log(` Write src/${file}`)
102
140
  }
103
141
 
104
142
  // The scaffold package.json carries a placeholder name; set it from the
105
- // target folder.
143
+ // target folder. A core-level app gets no component framework dependency.
106
144
  let pkgPath = join(dir, "package.json")
107
145
  let pkg = JSON.parse(await readFile(pkgPath, "utf8"))
108
146
  pkg.name = packageName(dir)
147
+ if (template.level === "core") delete pkg.dependencies["@solidrt/components"]
109
148
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
110
149
 
111
150
  // Deps are declared in scaffold/package.json (Solid peers resolve via
@@ -9,6 +9,7 @@ import { z } from "zod"
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
12
+ import { resolve } from "node:path"
12
13
  import { DEV_PORT } from "../dev-server"
13
14
 
14
15
  const CONTROL_BASE = `http://127.0.0.1:${DEV_PORT}/__control__`
@@ -44,17 +45,39 @@ let CLIENT_ARG = z
44
45
  .describe("Client id from list_clients (default: the only connected client)")
45
46
  .optional()
46
47
 
47
- let TOOLS: { name: string; description: string; inputSchema: Record<string, z.ZodTypeAny> }[] = [
48
+ let SAVE_TO_ARG = z
49
+ .string()
50
+ .describe(
51
+ "Also write the PNG to this file path (relative paths resolve against the project root; parent directories are created)",
52
+ )
53
+ .optional()
54
+
55
+ // readOnly marks tools that only inspect state; it is surfaced as the
56
+ // MCP-standard readOnlyHint annotation so agent harnesses that honor it can
57
+ // auto-approve the inspection majority. load, reload, and call_debug mutate
58
+ // the running app and keep the default hints (destructive, not idempotent);
59
+ // `annotations` overrides those defaults where a mutating tool is benign
60
+ // (watch: a reversible, idempotent toggle). Every tool gets
61
+ // openWorldHint: false - the bridge only ever talks to the local dev server.
62
+ let TOOLS: {
63
+ name: string
64
+ description: string
65
+ inputSchema: Record<string, z.ZodTypeAny>
66
+ readOnly?: boolean
67
+ annotations?: { destructiveHint?: boolean; idempotentHint?: boolean }
68
+ }[] = [
48
69
  {
49
70
  name: "list_clients",
71
+ readOnly: true,
50
72
  description:
51
- "List the app clients connected to the SolidRT dev server. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
73
+ "List the app clients connected to the SolidRT dev server. Returns `generation` (identity of this server run: client ids and log cursors are only valid within one generation, so if it changed since your last call, re-fetch ids and cursors) and `clients`. Each entry has id (pass it as `client` to the other tools), platform, runtime version (git describe; a -dirty suffix means the binary was built from uncommitted engine changes), build profile (debug/release), and the capability names compiled into that client's runtime. Use version/profile to check whether a connected binary contains a given engine change before debugging against it.",
52
74
  inputSchema: {},
53
75
  },
54
76
  {
55
77
  name: "get_logs",
78
+ readOnly: true,
56
79
  description:
57
- "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text) plus `latest`, the newest seq. Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload.",
80
+ "Read console output and runtime errors from connected app clients. Returns entries (seq, at, client, level, text; consecutive identical entries are collapsed into one with a `repeats` count and the run's last seq), plus `latest` (the newest seq) and `generation` (identity of this server run; if it changed since your last call, your seq cursor and client ids are stale - start over from since 0). Pass `since` (a seq or `latest` from a previous call) to only get newer entries; pass `wait_ms` to hold the call until new output arrives, e.g. right after triggering a reload; pass `level`/`contains` to filter, e.g. level \"error\" to skip chatty output.",
58
81
  inputSchema: {
59
82
  since: z
60
83
  .number()
@@ -64,39 +87,73 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
64
87
  wait_ms: z
65
88
  .number()
66
89
  .int()
67
- .describe("If nothing is newer than `since`, wait up to this many milliseconds for new output (max 30000)")
90
+ .describe("If nothing matches newer than `since`, wait up to this many milliseconds for new output (max 30000)")
91
+ .optional(),
92
+ level: z
93
+ .string()
94
+ .describe('Only return entries with one of these levels, comma-separated (e.g. "error" or "error,warn")')
95
+ .optional(),
96
+ contains: z
97
+ .string()
98
+ .describe("Only return entries whose text contains this substring (case-insensitive)")
68
99
  .optional(),
69
100
  },
70
101
  },
71
102
  {
72
103
  name: "get_stats",
104
+ readOnly: true,
73
105
  description:
74
- "Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count.",
106
+ "Performance statistics from a running app client: fps, CPU%, memory, smoothed JS/layout/paint/hover frame times (ms), setProperty writes per frame, demand-gate reuse/skip counts per second, and live texture count. Layout-activity counters cover the last full rebuild, raw: nodes (live node count, mounted AND detached), mountedNodes/orphanNodes (live at query time: nodes reachable from the root vs not - orphans growing at a stable tree shape mean an unmount leak; absent when no engine is running), measureCalls (text measures; mostly cache hits, cheap), paraShapes (paragraphs actually shaped; the expensive signal - high layoutMs with near-zero paraShapes means the cost is not text shaping), dirtiedNodes (layout caches cleared by property writes since the previous rebuild; how much of the tree a write burst invalidated), cacheGets/cacheHits (layout-cache lookups during the rebuild; a hit on a container skips its whole subtree, so a healthy incremental rebuild shows a near-100% hit rate - a low rate at scale means the layout cache is being defeated).",
75
107
  inputSchema: { client: CLIENT_ARG },
76
108
  },
77
109
  {
78
110
  name: "get_render_tree",
111
+ readOnly: true,
79
112
  description:
80
- "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where.",
81
- inputSchema: { client: CLIENT_ARG },
113
+ "Snapshot of a running app client's render tree: node id, kind, window-relative box (x, y, width, height), text content, and children. Use it to verify what the app actually rendered and where. Whole trees get large: prefer `query` to find nodes by kind or text first, then `root` + `depth` to inspect the region around a match. A node whose children were cut off by `depth` carries `childCount`; descend into it with root=<its id>.",
114
+ inputSchema: {
115
+ root: z
116
+ .number()
117
+ .int()
118
+ .describe("Only return the subtree under this node id (default: the whole tree)")
119
+ .optional(),
120
+ depth: z
121
+ .number()
122
+ .int()
123
+ .describe("Levels of children to include below the root (default: unlimited; 0 = the root node only)")
124
+ .optional(),
125
+ query: z
126
+ .string()
127
+ .describe(
128
+ "Search instead of snapshot: return `matches`, nodes whose kind equals or text contains this " +
129
+ "(case-insensitive), each with a `path` of ancestor ids from the search root. Combine with `root` to " +
130
+ "scope the search; `depth` is ignored.",
131
+ )
132
+ .optional(),
133
+ client: CLIENT_ARG,
134
+ },
82
135
  },
83
136
  {
84
137
  name: "get_snapshot",
138
+ readOnly: true,
85
139
  description:
86
- "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box.",
140
+ "Capture a PNG image of any node in a running app client's render tree, by node id (get ids from get_render_tree). Returns the rendered pixels of that node's subtree, so you can see what the app actually drew. The node must be currently mounted and have a non-zero layout box. Works on an idle client (the capture requests its own frame); a timeout means the client's JS thread is busy or wedged, not that the app is idle.",
87
141
  inputSchema: {
88
142
  nodeId: z.number().int().describe("Id of the node to capture, from get_render_tree"),
143
+ save_to: SAVE_TO_ARG,
89
144
  client: CLIENT_ARG,
90
145
  },
91
146
  },
92
147
  {
93
148
  name: "get_gpu_resources",
149
+ readOnly: true,
94
150
  description:
95
151
  "Inventory of a running app client's GPU resources: textures (id, size, whether a shader renders into it), vertex buffers (id, byteLength), and shader/pipeline targets (output textureId, kind, bufferId, topology, drawCount, depth, attribute layout, bound sampler texture ids, last-applied uniform values). Use it when the render tree is just a <texture> leaf and the interesting state lives behind it; follow up with get_texture or get_buffer to see contents.",
96
152
  inputSchema: { client: CLIENT_ARG },
97
153
  },
98
154
  {
99
155
  name: "get_texture",
156
+ readOnly: true,
100
157
  description:
101
158
  "Read back any GPU texture from a running app client as a PNG, by texture id (from get_gpu_resources, or the id returned by createImage/createShader/createPipeline in app code). Works on sampled textures (atlases, data textures) and shader/pipeline render targets alike, without needing a frame. Pass x/y/width/height to crop, e.g. one tile of an atlas.",
102
159
  inputSchema: {
@@ -105,11 +162,13 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
105
162
  y: z.number().int().describe("Crop rect top edge in texture pixels").optional(),
106
163
  width: z.number().int().describe("Crop rect width in texture pixels").optional(),
107
164
  height: z.number().int().describe("Crop rect height in texture pixels").optional(),
165
+ save_to: SAVE_TO_ARG,
108
166
  client: CLIENT_ARG,
109
167
  },
110
168
  },
111
169
  {
112
170
  name: "get_buffer",
171
+ readOnly: true,
113
172
  description:
114
173
  "Read back part of a GPU vertex buffer from a running app client, decoded to numbers. Returns values plus byteOffset/byteLength actually read and bufferByteLength. Reads are capped at 64 KiB per call; page through larger buffers with offset. Use it to verify geometry after a writeBuffer, e.g. the dynamic sprite tail of a vertex buffer.",
115
174
  inputSchema: {
@@ -122,6 +181,7 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
122
181
  },
123
182
  {
124
183
  name: "list_debug",
184
+ readOnly: true,
125
185
  description:
126
186
  "List the debug commands the running app registered via registerDebug from srt:dev. Returns the command names; call one with call_debug. Empty when the app registered none.",
127
187
  inputSchema: { client: CLIENT_ARG },
@@ -139,9 +199,26 @@ let TOOLS: { name: string; description: string; inputSchema: Record<string, z.Zo
139
199
  {
140
200
  name: "reload",
141
201
  description:
142
- "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. Follow with get_logs to see runtime output from the reloaded app.",
202
+ "Rebuild the app from source and push it to every connected client. Call this after editing the app's .tsx/.jsx source to apply the changes: it bundles once and reloads all clients, so a burst of edits becomes a single explicit reload. Returns the number of clients reloaded, or a build error if the source failed to compile. A successful reload re-enables the file watcher if you paused it with the watch tool. Follow with get_logs to see runtime output from the reloaded app.",
143
203
  inputSchema: {},
144
204
  },
205
+ {
206
+ name: "load",
207
+ description:
208
+ "Load an app entry: bundle the given .tsx/.jsx source file and push it to every connected client, replacing whatever is running. Use it when the dev server has no app loaded yet, or to switch to a different app; later reload calls rebuild this entry. Returns the number of clients loaded, or a build error if the source failed to compile. A successful load re-enables the file watcher if you paused it with the watch tool.",
209
+ inputSchema: {
210
+ entry: z.string().describe("App entry source file to load (relative paths resolve against the project root)"),
211
+ },
212
+ },
213
+ {
214
+ name: "watch",
215
+ annotations: { destructiveHint: false, idempotentHint: true },
216
+ description:
217
+ "Pause or resume the dev server's automatic reload-on-save. The srt file watcher pushes a rebuild whenever app source changes on disk; call watch with enabled: false BEFORE creating or editing source files so your half-finished work is not pushed to the user's screens mid-burst, then apply everything with one explicit reload (a successful reload or load re-enables the watcher, so pause again before the next burst of file changes). The human's own saves auto-reload only while the watcher is enabled, so do not leave it paused when you stop working.",
218
+ inputSchema: {
219
+ enabled: z.boolean().describe("false pauses auto-reload-on-save, true resumes it"),
220
+ },
221
+ },
145
222
  ]
146
223
 
147
224
  function clientParam(args: any): string {
@@ -156,15 +233,34 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
156
233
  let params = new URLSearchParams()
157
234
  if (typeof args?.since === "number") params.set("since", String(args.since))
158
235
  if (typeof args?.wait_ms === "number") params.set("wait", String(args.wait_ms))
236
+ if (typeof args?.level === "string") params.set("level", args.level)
237
+ if (typeof args?.contains === "string") params.set("contains", args.contains)
159
238
  let qs = params.toString()
160
239
  return control(qs ? `/logs?${qs}` : "/logs")
161
240
  }
162
241
  case "get_stats":
163
242
  return control(`/stats${clientParam(args)}`)
164
- case "get_render_tree":
165
- return control(`/tree${clientParam(args)}`)
243
+ case "get_render_tree": {
244
+ let params = new URLSearchParams()
245
+ if (typeof args?.root === "number") params.set("root", String(args.root))
246
+ if (typeof args?.depth === "number") params.set("depth", String(args.depth))
247
+ if (typeof args?.query === "string") params.set("query", args.query)
248
+ if (typeof args?.client === "number") params.set("client", String(args.client))
249
+ let qs = params.toString()
250
+ return control(qs ? `/tree?${qs}` : "/tree")
251
+ }
166
252
  case "reload":
167
253
  return control("/reload", "POST")
254
+ case "load": {
255
+ if (typeof args?.entry !== "string" || !args.entry) return { ok: false, message: "load requires an entry path" }
256
+ // Resolved here in the bridge: this process runs at the project root,
257
+ // the dev server may not.
258
+ return control("/load", "POST", { entry: resolve(args.entry) })
259
+ }
260
+ case "watch": {
261
+ if (typeof args?.enabled !== "boolean") return { ok: false, message: "watch requires enabled: true or false" }
262
+ return control("/watch", "POST", { enabled: args.enabled })
263
+ }
168
264
  case "get_snapshot": {
169
265
  if (typeof args?.nodeId !== "number") return { ok: false, message: "get_snapshot requires a numeric nodeId" }
170
266
  let params = new URLSearchParams({ node: String(args.nodeId) })
@@ -204,15 +300,29 @@ async function callTool(name: string, args: any): Promise<ControlResult> {
204
300
  }
205
301
  }
206
302
 
207
- function toContent(name: string, result: ControlResult): CallToolResult {
303
+ async function toContent(name: string, result: ControlResult, args?: any): Promise<CallToolResult> {
208
304
  if (!result.ok) return { content: [{ type: "text", text: result.message }], isError: true }
209
305
  if (name === "get_snapshot" || name === "get_texture") {
210
306
  let { pngBase64, width, height } = result.body
211
307
  let label = name === "get_snapshot" ? "Captured node snapshot" : "Texture contents"
308
+ let text = `${label}: ${width}x${height} px`
309
+ // save_to is handled here in the bridge, not by the dev server: this
310
+ // process runs on the caller's machine, so the path lands where the
311
+ // agent expects it. The image content block alone is a dead end for
312
+ // that - the model sees the pixels but never the bytes.
313
+ if (typeof args?.save_to === "string") {
314
+ let path = resolve(args.save_to)
315
+ try {
316
+ await Bun.write(path, Buffer.from(pngBase64, "base64"))
317
+ text += `, saved to ${path}`
318
+ } catch (e) {
319
+ return { content: [{ type: "text", text: `Captured, but saving to ${path} failed: ${e}` }], isError: true }
320
+ }
321
+ }
212
322
  return {
213
323
  content: [
214
324
  { type: "image", data: pngBase64, mimeType: "image/png" },
215
- { type: "text", text: `${label}: ${width}x${height} px` },
325
+ { type: "text", text },
216
326
  ],
217
327
  }
218
328
  }
@@ -225,8 +335,12 @@ export async function runMcpCommand() {
225
335
  for (let tool of TOOLS) {
226
336
  server.registerTool(
227
337
  tool.name,
228
- { description: tool.description, inputSchema: tool.inputSchema },
229
- async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {})),
338
+ {
339
+ description: tool.description,
340
+ inputSchema: tool.inputSchema,
341
+ annotations: { readOnlyHint: !!tool.readOnly, openWorldHint: false, ...tool.annotations },
342
+ },
343
+ async (args: any) => toContent(tool.name, await callTool(tool.name, args ?? {}), args),
230
344
  )
231
345
  }
232
346
 
package/src/dev-server.ts CHANGED
@@ -51,6 +51,26 @@ export async function sendStats(stats: boolean) {
51
51
  await post("/stats", { stats })
52
52
  }
53
53
 
54
+ /** Latch the auto-reload flag on the server (repl `watch on|off`). */
55
+ export async function sendWatch(enabled: boolean) {
56
+ await post("/watch", { enabled })
57
+ }
58
+
59
+ /**
60
+ * Whether the watcher may auto-reload: agents pause it via the MCP watch
61
+ * tool, latched on the server. Fails open so an unreachable server surfaces
62
+ * as a reload error, not a silently ignored change.
63
+ */
64
+ export async function watchAllowed(): Promise<boolean> {
65
+ try {
66
+ let resp = await fetch(`${INTERNAL_BASE}/watch`)
67
+ if (!resp.ok) return true
68
+ return (await resp.json()).enabled !== false
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+
54
74
  export type ClientEntry = {
55
75
  id: number
56
76
  platform: string
package/src/main.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { values, command, validateArgs, printUsage } from "./args"
4
4
  import { runInitCommand } from "./commands/init"
5
5
  import { runBundleCommand } from "./commands/bundle"
6
+ import { runCheckCommand } from "./commands/check"
6
7
  import { runPackCommand } from "./commands/pack"
7
8
  import { runRenderCommand } from "./commands/render"
8
9
  import { runServerCommand } from "./commands/server"
@@ -41,6 +42,8 @@ if (command === "init") {
41
42
  await runInitCommand()
42
43
  } else if (command === "bundle") {
43
44
  await runBundleCommand()
45
+ } else if (command === "check") {
46
+ await runCheckCommand()
44
47
  } else if (command === "pack") {
45
48
  await runPackCommand()
46
49
  } else if (command === "render") {
package/src/prompt.ts CHANGED
@@ -15,16 +15,24 @@ export function text(message: string, def = ""): Promise<string> {
15
15
  })
16
16
  }
17
17
 
18
+ export interface SelectOption {
19
+ label: string
20
+ value: string
21
+ }
22
+
18
23
  // Minimal arrow-key single-select prompt, built on node:readline (same
19
24
  // dependency-free approach as repl.ts). Renders the option list, moves the
20
- // highlight on up/down, resolves the chosen value on enter. Callers guard on
21
- // process.stdin.isTTY; a non-TTY stdin here resolves the first option rather
22
- // than hanging on input that will never arrive.
23
- export function select(message: string, options: string[]): Promise<string> {
25
+ // highlight on up/down, resolves the chosen value on enter. Options are plain
26
+ // strings or { label, value } pairs when the display text differs from the
27
+ // resolved value. Callers guard on process.stdin.isTTY; a non-TTY stdin here
28
+ // resolves the first option rather than hanging on input that will never
29
+ // arrive.
30
+ export function select(message: string, options: Array<string | SelectOption>): Promise<string> {
31
+ let items = options.map((o) => (typeof o === "string" ? { label: o, value: o } : o))
24
32
  return new Promise((resolve) => {
25
33
  let input = process.stdin
26
34
  let output = process.stdout
27
- if (!input.isTTY) return resolve(options[0]!)
35
+ if (!input.isTTY) return resolve(items[0]!.value)
28
36
 
29
37
  let selected = 0
30
38
  emitKeypressEvents(input)
@@ -34,13 +42,13 @@ export function select(message: string, options: string[]): Promise<string> {
34
42
  let render = (first = false) => {
35
43
  // After the first paint the cursor sits below the block; move it back up
36
44
  // to the message line so the list redraws in place.
37
- if (!first) output.write(`\x1b[${options.length + 1}A`)
45
+ if (!first) output.write(`\x1b[${items.length + 1}A`)
38
46
  output.write(`\x1b[K? ${message}\n`)
39
- for (let i = 0; i < options.length; i++) {
47
+ for (let i = 0; i < items.length; i++) {
40
48
  let active = i === selected
41
49
  let pointer = active ? "\x1b[36m> " : " "
42
50
  let reset = active ? "\x1b[0m" : ""
43
- output.write(`\x1b[K${pointer}${options[i]}${reset}\n`)
51
+ output.write(`\x1b[K${pointer}${items[i]!.label}${reset}\n`)
44
52
  }
45
53
  }
46
54
 
@@ -53,15 +61,15 @@ export function select(message: string, options: string[]): Promise<string> {
53
61
  let onKey = (_str: string, key: { name: string; ctrl: boolean } | undefined) => {
54
62
  if (!key) return
55
63
  if (key.name === "up") {
56
- selected = (selected - 1 + options.length) % options.length
64
+ selected = (selected - 1 + items.length) % items.length
57
65
  render()
58
66
  } else if (key.name === "down") {
59
- selected = (selected + 1) % options.length
67
+ selected = (selected + 1) % items.length
60
68
  render()
61
69
  } else if (key.name === "return" || key.name === "enter") {
62
70
  cleanup()
63
71
  output.write("\n")
64
- resolve(options[selected]!)
72
+ resolve(items[selected]!.value)
65
73
  } else if (key.ctrl && (key.name === "c" || key.name === "d")) {
66
74
  cleanup()
67
75
  output.write("\n")
package/src/repl.ts CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline"
2
2
  import { resolve, dirname } from "path"
3
3
  import { readdirSync } from "node:fs"
4
4
  import { state, print, printErr, shutdown } from "./util"
5
- import { buildReload, getClients, sendReload, sendStop, sendStats, showBuildFailure } from "./dev-server"
5
+ import { buildReload, getClients, sendReload, sendStop, sendStats, sendWatch, showBuildFailure } from "./dev-server"
6
6
  import { bundle } from "./bundler"
7
7
  import { startWatcher, stopWatcher } from "./watcher"
8
8
 
@@ -133,7 +133,7 @@ async function cmdLoad(file: string) {
133
133
  print(`[cli] Loaded ${file}`)
134
134
  }
135
135
 
136
- let COMMANDS = ["load ", "stop", "reload", "list", "stats", "quit", "exit", "help"]
136
+ let COMMANDS = ["load ", "stop", "reload", "list", "stats", "watch ", "quit", "exit", "help"]
137
137
  let LOAD_EXTENSIONS = [".tsx", ".srt.js", ".srt.bin"]
138
138
 
139
139
  function completer(line: string): [string[], string] {
@@ -186,6 +186,11 @@ export function startRepl() {
186
186
  guard(cmdList())
187
187
  } else if (cmd === "stats" || cmd.startsWith("stats ")) {
188
188
  guard(cmdStats(cmd.slice(6).trim()))
189
+ } else if (cmd === "watch on" || cmd === "watch off") {
190
+ // Manual override for the agent-latched auto-reload pause (an agent
191
+ // that died mid-edit leaves it off; its reload normally restores it).
192
+ let enabled = cmd === "watch on"
193
+ guard(sendWatch(enabled).then(() => print(`[cli] Auto-reload on change ${enabled ? "on" : "off"}`)))
189
194
  } else if (cmd === "quit" || cmd === "exit") {
190
195
  shutdown()
191
196
  } else if (cmd.startsWith("!")) {
@@ -201,7 +206,7 @@ export function startRepl() {
201
206
  )
202
207
  }
203
208
  } else if (cmd === "help") {
204
- print("Commands: load, stop, reload, list, stats, !<cmd>, quit, help")
209
+ print("Commands: load, stop, reload, list, stats, watch on|off, !<cmd>, quit, help")
205
210
  } else if (cmd) {
206
211
  print(`Unknown command: ${cmd}`)
207
212
  }
package/src/watcher.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { watch } from "node:fs"
2
2
  import { resolve, dirname } from "path"
3
3
  import { state, print, printErr } from "./util"
4
- import { buildReload, sendReload, showBuildFailure } from "./dev-server"
4
+ import { buildReload, sendReload, showBuildFailure, watchAllowed } from "./dev-server"
5
5
  import { bundle } from "./bundler"
6
6
 
7
7
  let currentWatcher: ReturnType<typeof watch> | null = null
@@ -24,6 +24,11 @@ export function startWatcher() {
24
24
  if (!filename) return
25
25
  if (!/\.(tsx?|jsx?)$/.test(filename)) return
26
26
 
27
+ if (!(await watchAllowed())) {
28
+ print(`[cli] Change detected: ${filename} (auto-reload paused by agent; "watch on" resumes)`)
29
+ return
30
+ }
31
+
27
32
  print(`[cli] Change detected: ${filename}`)
28
33
  let result = await bundle(state.source)
29
34
  if (!result) {