@skenora/tooling 0.1.2
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 +32 -0
- package/bin/skenora-mcp-http.mjs +131 -0
- package/bin/skenora-mcp.mjs +50 -0
- package/bin/skenora.mjs +388 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +109 -0
- package/dist/doctor.js.map +1 -0
- package/dist/examples.d.ts +26 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +207 -0
- package/dist/examples.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-http.d.ts +16 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +279 -0
- package/dist/mcp-http.js.map +1 -0
- package/dist/mcp.d.ts +79 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +817 -0
- package/dist/mcp.js.map +1 -0
- package/dist/resources.d.ts +24 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +141 -0
- package/dist/resources.js.map +1 -0
- package/dist/wgsl.d.ts +42 -0
- package/dist/wgsl.d.ts.map +1 -0
- package/dist/wgsl.js +133 -0
- package/dist/wgsl.js.map +1 -0
- package/package.json +53 -0
- package/resources/docs/diagnostics.md +16 -0
- package/resources/docs/overview.md +26 -0
- package/resources/docs/runtime-probe.md +17 -0
- package/resources/examples/gallery.json +1818 -0
- package/resources/examples/invalid-scene.json +9 -0
- package/resources/examples/valid-scene.json +69 -0
- package/resources/llms-full.txt +52 -0
- package/resources/llms.txt +19 -0
- package/resources/manifest.json +97 -0
- package/resources/schemas/example-manifest.schema.json +454 -0
- package/resources/schemas/scene-check-report.schema.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @skenora/tooling
|
|
2
|
+
|
|
3
|
+
Offline developer tooling and MCP adapters for Skenora. The package owns the
|
|
4
|
+
`skenora` CLI, versioned documentation/example/schema resources, static scene
|
|
5
|
+
checks, Node/browser doctor reports, a read-only stdio/loopback-HTTP server,
|
|
6
|
+
and transport-neutral MCP handlers with optional host-injected ScenePlan tools.
|
|
7
|
+
|
|
8
|
+
It is deliberately not a dependency of browser products or Runtime. Run
|
|
9
|
+
`skenora --help` after installing the package.
|
|
10
|
+
|
|
11
|
+
The installed immutable gallery is available through `skenora examples
|
|
12
|
+
list|search|show|pull`. The default MCP exposes list/search/get/source without
|
|
13
|
+
filesystem mutation; only the CLI `pull` command writes. All manifests and
|
|
14
|
+
source artifacts are verified against the package-versioned resource hash.
|
|
15
|
+
|
|
16
|
+
Use `skenora mcp` for stdio or `skenora mcp --transport http` for a read-only
|
|
17
|
+
loopback Web MCP. Applications can import `@skenora/tooling/mcp` and
|
|
18
|
+
`@skenora/tooling/mcp/http` to inject an authenticated, handle-scoped
|
|
19
|
+
`ScenePlanGateway` adapter. Authoring composition requires an explicit
|
|
20
|
+
authorization callback; HTTP additionally requires authentication. Write
|
|
21
|
+
authorization receives a canonical plan hash and revision/idempotency intent,
|
|
22
|
+
and scene inspection returns a revision-bound paginated safe outline. See the
|
|
23
|
+
[Web MCP guide](https://github.com/zhangjiadi225/skenora/blob/main/docs/WEB_MCP.md).
|
|
24
|
+
|
|
25
|
+
`@skenora/tooling/wgsl` is an isolated optional audience that reflects trusted
|
|
26
|
+
MaterialProgram-generated WGSL with `@vgpu/wgsl` and can validate it with a
|
|
27
|
+
caller-supplied WebGPU device. `skenora wgsl inspect` runs the parser/reflection
|
|
28
|
+
half and therefore reports `unverified` until a device participates.
|
|
29
|
+
|
|
30
|
+
Repository maintainers run `pnpm check:tooling-resources` for manifest drift.
|
|
31
|
+
After an intentional resource edit, `pnpm baseline:tooling-resources` updates
|
|
32
|
+
hashes and the package version; unlisted or missing resource files still fail.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
createSkenoraMcpHttpHandler,
|
|
6
|
+
createSkenoraMcpServer,
|
|
7
|
+
} from "../dist/index.js";
|
|
8
|
+
|
|
9
|
+
export async function runMcpHttpServer(options = {}) {
|
|
10
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
11
|
+
const port = options.port ?? 8787;
|
|
12
|
+
const path = options.path ?? "/mcp";
|
|
13
|
+
const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
|
|
14
|
+
const mcp = options.server ?? createSkenoraMcpServer();
|
|
15
|
+
const handler = createSkenoraMcpHttpHandler(mcp, {
|
|
16
|
+
path,
|
|
17
|
+
maxBodyBytes,
|
|
18
|
+
allowedHosts: options.allowedHosts ?? [hostname, "localhost"],
|
|
19
|
+
...(options.allowedOrigins
|
|
20
|
+
? { allowedOrigins: options.allowedOrigins }
|
|
21
|
+
: {}),
|
|
22
|
+
...(options.authenticate ? { authenticate: options.authenticate } : {}),
|
|
23
|
+
...(options.authorizationMetadataUrl
|
|
24
|
+
? { authorizationMetadataUrl: options.authorizationMetadataUrl }
|
|
25
|
+
: {}),
|
|
26
|
+
});
|
|
27
|
+
const http = createServer(async (request, response) => {
|
|
28
|
+
const disconnected = new AbortController();
|
|
29
|
+
const abortRequest = () => disconnected.abort();
|
|
30
|
+
const abortResponse = () => {
|
|
31
|
+
if (!response.writableEnded) disconnected.abort();
|
|
32
|
+
};
|
|
33
|
+
request.once("aborted", abortRequest);
|
|
34
|
+
response.once("close", abortResponse);
|
|
35
|
+
try {
|
|
36
|
+
const body = await readBody(request, maxBodyBytes);
|
|
37
|
+
const host = request.headers.host ?? `${hostname}:${port}`;
|
|
38
|
+
const method = request.method ?? "GET";
|
|
39
|
+
const webRequest = new Request(`http://${host}${request.url ?? "/"}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers: toWebHeaders(request.headers),
|
|
42
|
+
signal: disconnected.signal,
|
|
43
|
+
...(body.length === 0 || method === "GET" || method === "HEAD"
|
|
44
|
+
? {}
|
|
45
|
+
: { body }),
|
|
46
|
+
});
|
|
47
|
+
const webResponse = await handler(webRequest);
|
|
48
|
+
response.statusCode = webResponse.status;
|
|
49
|
+
for (const [name, value] of webResponse.headers) {
|
|
50
|
+
response.setHeader(name, value);
|
|
51
|
+
}
|
|
52
|
+
response.end(Buffer.from(await webResponse.arrayBuffer()));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
response.statusCode =
|
|
55
|
+
error instanceof RequestBodyTooLargeError ? 413 : 500;
|
|
56
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
57
|
+
response.end(
|
|
58
|
+
error instanceof RequestBodyTooLargeError
|
|
59
|
+
? "Request body too large\n"
|
|
60
|
+
: "Internal error\n",
|
|
61
|
+
);
|
|
62
|
+
} finally {
|
|
63
|
+
request.off("aborted", abortRequest);
|
|
64
|
+
response.off("close", abortResponse);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
await new Promise((resolve, reject) => {
|
|
68
|
+
http.once("error", reject);
|
|
69
|
+
http.listen(port, hostname, () => {
|
|
70
|
+
http.off("error", reject);
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
`Skenora MCP listening on http://${hostname}:${port}${path}\n`,
|
|
76
|
+
);
|
|
77
|
+
return http;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readBody(request, maxBodyBytes) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let bytes = 0;
|
|
84
|
+
let settled = false;
|
|
85
|
+
const onData = (chunk) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
bytes += chunk.length;
|
|
88
|
+
if (bytes > maxBodyBytes) {
|
|
89
|
+
settled = true;
|
|
90
|
+
chunks.length = 0;
|
|
91
|
+
request.off("data", onData);
|
|
92
|
+
request.resume();
|
|
93
|
+
reject(new RequestBodyTooLargeError());
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
chunks.push(chunk);
|
|
97
|
+
};
|
|
98
|
+
request.on("data", onData);
|
|
99
|
+
request.on("end", () => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
resolve(Buffer.concat(chunks));
|
|
103
|
+
});
|
|
104
|
+
request.on("error", (error) => {
|
|
105
|
+
if (settled) return;
|
|
106
|
+
settled = true;
|
|
107
|
+
reject(error);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class RequestBodyTooLargeError extends Error {}
|
|
113
|
+
|
|
114
|
+
function toWebHeaders(input) {
|
|
115
|
+
const headers = new Headers();
|
|
116
|
+
for (const [name, value] of Object.entries(input)) {
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
for (const item of value) headers.append(name, item);
|
|
119
|
+
} else if (value !== undefined) {
|
|
120
|
+
headers.set(name, value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return headers;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
process.argv[1] &&
|
|
128
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
129
|
+
) {
|
|
130
|
+
await runMcpHttpServer();
|
|
131
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
SKENORA_MCP_LEGACY_PROTOCOL_VERSION,
|
|
6
|
+
SKENORA_MCP_PROTOCOL_VERSION,
|
|
7
|
+
createSkenoraMcpErrorResponse,
|
|
8
|
+
createSkenoraMcpServer,
|
|
9
|
+
getSkenoraMcpRequestProtocolVersion,
|
|
10
|
+
} from "../dist/index.js";
|
|
11
|
+
|
|
12
|
+
export async function runMcpServer(options = {}) {
|
|
13
|
+
const server = options.server ?? createSkenoraMcpServer();
|
|
14
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
15
|
+
let protocolVersion;
|
|
16
|
+
for await (const line of lines) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
let request;
|
|
19
|
+
try {
|
|
20
|
+
request = JSON.parse(line);
|
|
21
|
+
} catch {
|
|
22
|
+
write(createSkenoraMcpErrorResponse(null, -32700, "Parse error"));
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
protocolVersion ??= selectOpeningProtocolVersion(request);
|
|
26
|
+
const response = await server.handle(request, {
|
|
27
|
+
transport: "stdio",
|
|
28
|
+
protocolVersion,
|
|
29
|
+
});
|
|
30
|
+
if (response) write(response);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function selectOpeningProtocolVersion(request) {
|
|
35
|
+
const claimed = getSkenoraMcpRequestProtocolVersion(request);
|
|
36
|
+
return request?.method === "server/discover" || claimed !== undefined
|
|
37
|
+
? SKENORA_MCP_PROTOCOL_VERSION
|
|
38
|
+
: SKENORA_MCP_LEGACY_PROTOCOL_VERSION;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function write(value) {
|
|
42
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
process.argv[1] &&
|
|
47
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
48
|
+
) {
|
|
49
|
+
await runMcpServer();
|
|
50
|
+
}
|
package/bin/skenora.mjs
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, relative, resolve } from "node:path";
|
|
4
|
+
import { stdin, stdout } from "node:process";
|
|
5
|
+
import {
|
|
6
|
+
checkSceneInput,
|
|
7
|
+
createDoctorReport,
|
|
8
|
+
getInstalledSkenoraExample,
|
|
9
|
+
getInstalledSkenoraExampleSource,
|
|
10
|
+
getToolingResource,
|
|
11
|
+
listInstalledSkenoraExamples,
|
|
12
|
+
listToolingResources,
|
|
13
|
+
searchInstalledSkenoraExamples,
|
|
14
|
+
searchToolingResources,
|
|
15
|
+
verifyToolingResources,
|
|
16
|
+
} from "../dist/index.js";
|
|
17
|
+
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
const json = takeFlag(args, "--json");
|
|
20
|
+
const command = args.shift();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
switch (command) {
|
|
24
|
+
case "check":
|
|
25
|
+
await runCheck(args, json);
|
|
26
|
+
break;
|
|
27
|
+
case "doctor":
|
|
28
|
+
await runDoctor(args, json);
|
|
29
|
+
break;
|
|
30
|
+
case "docs":
|
|
31
|
+
await runResources(args, "documentation", json);
|
|
32
|
+
break;
|
|
33
|
+
case "examples":
|
|
34
|
+
await runExamples(args, json);
|
|
35
|
+
break;
|
|
36
|
+
case "resources":
|
|
37
|
+
await runResourceMaintenance(args, json);
|
|
38
|
+
break;
|
|
39
|
+
case "wgsl":
|
|
40
|
+
await runWgsl(args, json);
|
|
41
|
+
break;
|
|
42
|
+
case "mcp": {
|
|
43
|
+
await runMcp(args);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "help":
|
|
47
|
+
case "--help":
|
|
48
|
+
case "-h":
|
|
49
|
+
case undefined:
|
|
50
|
+
stdout.write(helpText());
|
|
51
|
+
break;
|
|
52
|
+
default:
|
|
53
|
+
throw new CliError(`Unknown command: ${command}`, 64);
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
if (json) stdout.write(`${JSON.stringify({ error: message }, null, 2)}\n`);
|
|
58
|
+
else process.stderr.write(`skenora: ${message}\n`);
|
|
59
|
+
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runCheck(commandArgs, asJson) {
|
|
63
|
+
const inputPath = commandArgs.shift();
|
|
64
|
+
if (!inputPath || commandArgs.length)
|
|
65
|
+
throw new CliError("Usage: skenora check <file|-> [--json]", 64);
|
|
66
|
+
const text =
|
|
67
|
+
inputPath === "-" ? await readStdin() : await readFile(inputPath, "utf8");
|
|
68
|
+
const report = checkSceneInput(text);
|
|
69
|
+
output(report, asJson, formatCheckReport);
|
|
70
|
+
if (report.status === "invalid") process.exitCode = 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function runDoctor(commandArgs, asJson) {
|
|
74
|
+
const reportFlag = takeOption(commandArgs, "--probe-report");
|
|
75
|
+
if (commandArgs.length)
|
|
76
|
+
throw new CliError(
|
|
77
|
+
"Usage: skenora doctor [--probe-report <file>] [--json]",
|
|
78
|
+
64,
|
|
79
|
+
);
|
|
80
|
+
const probe = reportFlag
|
|
81
|
+
? JSON.parse(await readFile(reportFlag, "utf8"))
|
|
82
|
+
: undefined;
|
|
83
|
+
const report = createDoctorReport(probe);
|
|
84
|
+
output(report, asJson, formatDoctorReport);
|
|
85
|
+
process.exitCode =
|
|
86
|
+
report.verdict === "healthy" ? 0 : report.verdict === "unhealthy" ? 1 : 2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runResources(commandArgs, kind, asJson) {
|
|
90
|
+
const action = commandArgs.shift() ?? "list";
|
|
91
|
+
if (action === "list") {
|
|
92
|
+
if (commandArgs.length)
|
|
93
|
+
throw new CliError(
|
|
94
|
+
`Usage: skenora ${kind === "example" ? "examples" : "docs"} list`,
|
|
95
|
+
64,
|
|
96
|
+
);
|
|
97
|
+
const entries = await listToolingResources(kind);
|
|
98
|
+
output(
|
|
99
|
+
entries,
|
|
100
|
+
asJson,
|
|
101
|
+
(items) =>
|
|
102
|
+
`${items.map((entry) => `${entry.id}\t${entry.title}`).join("\n")}\n`,
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (action === "get") {
|
|
107
|
+
const id = commandArgs.shift();
|
|
108
|
+
if (!id || commandArgs.length)
|
|
109
|
+
throw new CliError("A single resource ID is required", 64);
|
|
110
|
+
const resource = await getToolingResource(id);
|
|
111
|
+
if (resource.entry.kind !== kind)
|
|
112
|
+
throw new CliError(`Resource ${id} is not a ${kind} resource`, 64);
|
|
113
|
+
output(resource, asJson, (value) => value.text);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (action === "search") {
|
|
117
|
+
const query = commandArgs.join(" ").trim();
|
|
118
|
+
if (!query) throw new CliError("A search query is required", 64);
|
|
119
|
+
const entries = await searchToolingResources(query, { kind });
|
|
120
|
+
output(
|
|
121
|
+
entries,
|
|
122
|
+
asJson,
|
|
123
|
+
(items) =>
|
|
124
|
+
`${items.map((entry) => `${entry.id}\t${entry.title}`).join("\n")}\n`,
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new CliError(`Unknown resource action: ${action}`, 64);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function runExamples(commandArgs, asJson) {
|
|
132
|
+
const action = commandArgs.shift() ?? "list";
|
|
133
|
+
if (action === "list") {
|
|
134
|
+
if (commandArgs.length)
|
|
135
|
+
throw new CliError("Usage: skenora examples list [--json]", 64);
|
|
136
|
+
const examples = await listInstalledSkenoraExamples();
|
|
137
|
+
output(examples, asJson, formatExampleList);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (action === "search") {
|
|
141
|
+
const query = commandArgs.join(" ").trim();
|
|
142
|
+
if (!query) throw new CliError("A search query is required", 64);
|
|
143
|
+
const examples = await searchInstalledSkenoraExamples(query);
|
|
144
|
+
output(examples, asJson, formatExampleList);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (action === "show" || action === "get") {
|
|
148
|
+
const id = commandArgs.shift();
|
|
149
|
+
if (!id || commandArgs.length)
|
|
150
|
+
throw new CliError("A single example ID is required", 64);
|
|
151
|
+
const example = await getInstalledSkenoraExample(id);
|
|
152
|
+
output(example, asJson, (value) => `${JSON.stringify(value, null, 2)}\n`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (action === "pull") {
|
|
156
|
+
const id = commandArgs.shift();
|
|
157
|
+
const out = takeOption(commandArgs, "--out");
|
|
158
|
+
if (!id || !out || commandArgs.length)
|
|
159
|
+
throw new CliError(
|
|
160
|
+
"Usage: skenora examples pull <id> --out <directory>",
|
|
161
|
+
64,
|
|
162
|
+
);
|
|
163
|
+
const example = await getInstalledSkenoraExample(id);
|
|
164
|
+
const destination = resolve(out);
|
|
165
|
+
if (await stat(destination).catch(() => undefined))
|
|
166
|
+
throw new CliError(`Output directory already exists: ${destination}`, 73);
|
|
167
|
+
await mkdir(destination, { recursive: true });
|
|
168
|
+
await writeFile(
|
|
169
|
+
resolve(destination, "manifest.json"),
|
|
170
|
+
`${JSON.stringify(example.manifest, null, 2)}\n`,
|
|
171
|
+
"utf8",
|
|
172
|
+
);
|
|
173
|
+
for (const source of example.sources) {
|
|
174
|
+
const target = resolve(destination, source.path);
|
|
175
|
+
const fromDestination = relative(destination, target);
|
|
176
|
+
if (
|
|
177
|
+
fromDestination === "" ||
|
|
178
|
+
fromDestination === ".." ||
|
|
179
|
+
fromDestination.startsWith(`..${pathSeparator()}`)
|
|
180
|
+
)
|
|
181
|
+
throw new Error(`Unsafe installed example path: ${source.path}`);
|
|
182
|
+
const content = await getInstalledSkenoraExampleSource(
|
|
183
|
+
example.manifest.id,
|
|
184
|
+
source.path,
|
|
185
|
+
);
|
|
186
|
+
await mkdir(dirname(target), { recursive: true });
|
|
187
|
+
await writeFile(
|
|
188
|
+
target,
|
|
189
|
+
content.encoding === "utf8"
|
|
190
|
+
? content.content
|
|
191
|
+
: Buffer.from(content.content, "base64"),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
output(
|
|
195
|
+
{
|
|
196
|
+
id: example.manifest.id,
|
|
197
|
+
directory: destination,
|
|
198
|
+
files: example.sources.length + 1,
|
|
199
|
+
},
|
|
200
|
+
asJson,
|
|
201
|
+
(value) =>
|
|
202
|
+
`Pulled ${value.id} to ${value.directory} (${value.files} files).\n`,
|
|
203
|
+
);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
throw new CliError(`Unknown example action: ${action}`, 64);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function runResourceMaintenance(commandArgs, asJson) {
|
|
210
|
+
const action = commandArgs.shift();
|
|
211
|
+
if (action !== "verify" || commandArgs.length)
|
|
212
|
+
throw new CliError("Usage: skenora resources verify [--json]", 64);
|
|
213
|
+
const report = await verifyToolingResources();
|
|
214
|
+
output(report, asJson, (value) =>
|
|
215
|
+
value.valid
|
|
216
|
+
? `Verified ${value.checked} tooling resources.\n`
|
|
217
|
+
: `Resource verification failed:\n${value.failures
|
|
218
|
+
.map(
|
|
219
|
+
(failure) =>
|
|
220
|
+
`- ${failure.id}: ${failure.actual} != ${failure.expected}`,
|
|
221
|
+
)
|
|
222
|
+
.join("\n")}\n`,
|
|
223
|
+
);
|
|
224
|
+
if (!report.valid) process.exitCode = 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function runWgsl(commandArgs, asJson) {
|
|
228
|
+
const action = commandArgs.shift();
|
|
229
|
+
const inputPath = commandArgs.shift();
|
|
230
|
+
if (action !== "inspect" || !inputPath || commandArgs.length)
|
|
231
|
+
throw new CliError(
|
|
232
|
+
"Usage: skenora wgsl inspect <material-program.json> [--json]",
|
|
233
|
+
64,
|
|
234
|
+
);
|
|
235
|
+
const input = JSON.parse(await readFile(inputPath, "utf8"));
|
|
236
|
+
const { inspectMaterialProgramWgsl } = await import("../dist/wgsl.js");
|
|
237
|
+
const report = await inspectMaterialProgramWgsl(input);
|
|
238
|
+
output(report, asJson, (value) => {
|
|
239
|
+
const lines = [
|
|
240
|
+
`${value.status.toUpperCase()} parser=${value.parser} device=${value.deviceValidation}`,
|
|
241
|
+
...value.diagnostics.map(
|
|
242
|
+
(diagnostic) =>
|
|
243
|
+
`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`,
|
|
244
|
+
),
|
|
245
|
+
];
|
|
246
|
+
return `${lines.join("\n")}\n`;
|
|
247
|
+
});
|
|
248
|
+
process.exitCode =
|
|
249
|
+
report.status === "valid" ? 0 : report.status === "invalid" ? 1 : 2;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function runMcp(commandArgs) {
|
|
253
|
+
const transport = takeOption(commandArgs, "--transport") ?? "stdio";
|
|
254
|
+
if (transport === "stdio") {
|
|
255
|
+
if (commandArgs.length) {
|
|
256
|
+
throw new CliError("Usage: skenora mcp [--transport stdio]", 64);
|
|
257
|
+
}
|
|
258
|
+
const { runMcpServer } = await import("./skenora-mcp.mjs");
|
|
259
|
+
await runMcpServer();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (transport === "http") {
|
|
263
|
+
const portValue = takeOption(commandArgs, "--port");
|
|
264
|
+
const path = takeOption(commandArgs, "--path") ?? "/mcp";
|
|
265
|
+
if (commandArgs.length) {
|
|
266
|
+
throw new CliError(
|
|
267
|
+
"Usage: skenora mcp --transport http [--port <number>] [--path </mcp>]",
|
|
268
|
+
64,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const port = portValue === undefined ? 8787 : Number(portValue);
|
|
272
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
273
|
+
throw new CliError("MCP HTTP port must be an integer in 1..65535", 64);
|
|
274
|
+
}
|
|
275
|
+
const { runMcpHttpServer } = await import("./skenora-mcp-http.mjs");
|
|
276
|
+
await runMcpHttpServer({ port, path });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
throw new CliError(`Unknown MCP transport: ${transport}`, 64);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function output(value, asJson, format) {
|
|
283
|
+
stdout.write(asJson ? `${JSON.stringify(value, null, 2)}\n` : format(value));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function formatCheckReport(report) {
|
|
287
|
+
const lines = [
|
|
288
|
+
`${report.status.toUpperCase()} ${report.inputKind} (${report.source})`,
|
|
289
|
+
`entities=${report.summary.entities} materials=${report.summary.materials} resources=${report.summary.resources} compiled=${report.summary.compiled}`,
|
|
290
|
+
];
|
|
291
|
+
for (const diagnostic of report.diagnostics) {
|
|
292
|
+
lines.push(
|
|
293
|
+
`[${diagnostic.severity}] ${diagnostic.code} ${formatPath(diagnostic.path)}: ${diagnostic.message}`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return `${lines.join("\n")}\n`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatDoctorReport(report) {
|
|
300
|
+
const lines = [
|
|
301
|
+
`${report.verdict.toUpperCase()} runtime=${report.environment.runtime}`,
|
|
302
|
+
];
|
|
303
|
+
for (const diagnostic of report.diagnostics) {
|
|
304
|
+
lines.push(
|
|
305
|
+
`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`,
|
|
306
|
+
);
|
|
307
|
+
if (diagnostic.repair) lines.push(` repair: ${diagnostic.repair}`);
|
|
308
|
+
}
|
|
309
|
+
return `${lines.join("\n")}\n`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function formatExampleList(examples) {
|
|
313
|
+
return `${examples
|
|
314
|
+
.map(
|
|
315
|
+
({ manifest }) =>
|
|
316
|
+
`${manifest.id}\t${manifest.mode}\t${manifest.tier}\t${manifest.title}`,
|
|
317
|
+
)
|
|
318
|
+
.join("\n")}\n`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function pathSeparator() {
|
|
322
|
+
return process.platform === "win32" ? "\\" : "/";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function formatPath(path) {
|
|
326
|
+
if (!path?.length) return "$";
|
|
327
|
+
return path.reduce(
|
|
328
|
+
(value, segment) =>
|
|
329
|
+
typeof segment === "number"
|
|
330
|
+
? `${value}[${segment}]`
|
|
331
|
+
: `${value}.${segment}`,
|
|
332
|
+
"$",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function takeFlag(values, flag) {
|
|
337
|
+
const index = values.indexOf(flag);
|
|
338
|
+
if (index < 0) return false;
|
|
339
|
+
values.splice(index, 1);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function takeOption(values, flag) {
|
|
344
|
+
const index = values.indexOf(flag);
|
|
345
|
+
if (index < 0) return undefined;
|
|
346
|
+
const value = values[index + 1];
|
|
347
|
+
if (!value || value.startsWith("--"))
|
|
348
|
+
throw new CliError(`${flag} requires a value`, 64);
|
|
349
|
+
values.splice(index, 2);
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function readStdin() {
|
|
354
|
+
return new Promise((resolve, reject) => {
|
|
355
|
+
let value = "";
|
|
356
|
+
stdin.setEncoding("utf8");
|
|
357
|
+
stdin.on("data", (chunk) => (value += chunk));
|
|
358
|
+
stdin.on("end", () => resolve(value));
|
|
359
|
+
stdin.on("error", reject);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function helpText() {
|
|
364
|
+
return `Skenora developer tooling
|
|
365
|
+
|
|
366
|
+
Usage:
|
|
367
|
+
skenora check <file|-> [--json]
|
|
368
|
+
skenora doctor [--probe-report <file>] [--json]
|
|
369
|
+
skenora docs <list|get|search> [value] [--json]
|
|
370
|
+
skenora examples list [--json]
|
|
371
|
+
skenora examples search <query> [--json]
|
|
372
|
+
skenora examples show <id> [--json]
|
|
373
|
+
skenora examples pull <id> --out <directory> [--json]
|
|
374
|
+
skenora resources verify [--json]
|
|
375
|
+
skenora wgsl inspect <material-program.json> [--json]
|
|
376
|
+
skenora mcp [--transport stdio]
|
|
377
|
+
skenora mcp --transport http [--port <number>] [--path </mcp>]
|
|
378
|
+
|
|
379
|
+
Exit codes: 0 success/healthy, 1 invalid/unhealthy, 2 unverified, 64 usage.
|
|
380
|
+
`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
class CliError extends Error {
|
|
384
|
+
constructor(message, exitCode) {
|
|
385
|
+
super(message);
|
|
386
|
+
this.exitCode = exitCode;
|
|
387
|
+
}
|
|
388
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../src/doctor.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,oBAAoB,CAAC;AAE5B,wBAAgB,kBAAkB,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,mBAAmB,CAqCvE"}
|