@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.
- package/package.json +7 -6
- package/scaffold/AGENTS.md +120 -15
- package/scaffold/package.json +4 -4
- package/scaffold/templates/components/index.tsx +15 -0
- package/scaffold/templates/components/template.json +4 -0
- package/scaffold/templates/default/index.tsx +7 -9
- package/scaffold/templates/default/template.json +4 -0
- package/scaffold/templates/gallery/template.json +4 -0
- package/scaffold/templates/minimal/index.tsx +5 -2
- package/scaffold/templates/minimal/template.json +4 -0
- package/scaffold/tsconfig.json +1 -0
- package/server/control.ts +113 -14
- package/server/main.ts +7 -0
- package/server/state.ts +14 -0
- package/src/args.ts +4 -0
- package/src/artifacts.ts +2 -0
- package/src/bundler.ts +3 -1
- package/src/commands/check.ts +94 -0
- package/src/commands/init.ts +53 -14
- package/src/commands/mcp.ts +129 -15
- package/src/dev-server.ts +20 -0
- package/src/main.ts +3 -0
- package/src/prompt.ts +19 -11
- package/src/repl.ts +8 -3
- package/src/watcher.ts +6 -1
|
@@ -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
|
+
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
35
|
-
// point, the rest alphabetically.
|
|
36
|
-
async function listTemplates(): Promise<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
229
|
-
|
|
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.
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
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(
|
|
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[${
|
|
45
|
+
if (!first) output.write(`\x1b[${items.length + 1}A`)
|
|
38
46
|
output.write(`\x1b[K? ${message}\n`)
|
|
39
|
-
for (let i = 0; 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}${
|
|
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 +
|
|
64
|
+
selected = (selected - 1 + items.length) % items.length
|
|
57
65
|
render()
|
|
58
66
|
} else if (key.name === "down") {
|
|
59
|
-
selected = (selected + 1) %
|
|
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(
|
|
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) {
|