openuispec 0.1.47 → 0.2.1
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 +3 -1
- package/cli/init.ts +1 -0
- package/examples/social-app/AGENTS.md +1 -1
- package/examples/social-app/CLAUDE.md +1 -1
- package/mcp-server/index.ts +40 -0
- package/mcp-server/screenshot.ts +268 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -123,6 +123,7 @@ Or run directly: `openuispec mcp`
|
|
|
123
123
|
| `openuispec_get_contract` | Incremental edits | Get a single contract spec, optionally filtered to one variant |
|
|
124
124
|
| `openuispec_get_tokens` | Incremental edits | Get tokens for a specific category (color, typography, spacing, etc.) |
|
|
125
125
|
| `openuispec_get_locale` | Incremental edits | Get a single locale file, optionally filtered to specific keys |
|
|
126
|
+
| `openuispec_screenshot` | Visual verification | Take a screenshot of the generated web app at a specific route (requires `puppeteer`) |
|
|
126
127
|
|
|
127
128
|
The server includes **protocol-level instructions** that trigger on UI-related requests independently of CLAUDE.md rules — so even if CLAUDE.md is buried under other project rules, the MCP enforcement still works.
|
|
128
129
|
|
|
@@ -190,7 +191,8 @@ openuispec/
|
|
|
190
191
|
│ ├── index.ts # Entry point
|
|
191
192
|
│ └── init.ts # Project scaffolding + AI rules
|
|
192
193
|
├── mcp-server/ # MCP server (openuispec-mcp)
|
|
193
|
-
│
|
|
194
|
+
│ ├── index.ts # Stdio transport, 13 tools
|
|
195
|
+
│ └── screenshot.ts # Dev server + headless browser screenshot
|
|
194
196
|
├── check/ # Composite validation command
|
|
195
197
|
│ └── index.ts # Schema + semantic + readiness
|
|
196
198
|
├── drift/ # Drift detection (spec change tracking)
|
package/cli/init.ts
CHANGED
|
@@ -282,6 +282,7 @@ When the openuispec MCP server is configured, AI assistants should use these too
|
|
|
282
282
|
| \`openuispec_get_contract\` | Get a single contract spec, optionally filtered to one variant. |
|
|
283
283
|
| \`openuispec_get_tokens\` | Get tokens for a specific category (color, typography, spacing, etc.). |
|
|
284
284
|
| \`openuispec_get_locale\` | Get a single locale file, optionally filtered to specific keys. |
|
|
285
|
+
| \`openuispec_screenshot\` | Take a screenshot of the generated web app at a route (requires \`puppeteer\`). |
|
|
285
286
|
|
|
286
287
|
## CLI commands
|
|
287
288
|
|
package/mcp-server/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { loadTargetDrift } from "../drift/index.js";
|
|
|
25
25
|
import { readFileSync as fsReadFileSync, existsSync, readdirSync } from "node:fs";
|
|
26
26
|
import { relative, resolve } from "node:path";
|
|
27
27
|
import YAML from "yaml";
|
|
28
|
+
import { takeScreenshot } from "./screenshot.js";
|
|
28
29
|
|
|
29
30
|
// ── resolve project cwd ──────────────────────────────────────────────
|
|
30
31
|
|
|
@@ -111,6 +112,11 @@ FOCUSED GETTERS (prefer these for incremental edits over read_specs):
|
|
|
111
112
|
- openuispec_check(target, screens?, contracts?) — scoped audit for specific screens/contracts
|
|
112
113
|
Use read_specs for full-project generation; use focused getters when editing one screen or contract.
|
|
113
114
|
|
|
115
|
+
VISUAL VERIFICATION:
|
|
116
|
+
- openuispec_screenshot(route, viewport?, theme?) — screenshot the generated web app at a route.
|
|
117
|
+
Starts the dev server automatically. Use after generation to visually verify UI matches the spec.
|
|
118
|
+
Requires puppeteer (npm install -g puppeteer).
|
|
119
|
+
|
|
114
120
|
Skip these tools ONLY when the request is purely non-UI (API logic, database, infrastructure, etc.)
|
|
115
121
|
or explicitly platform-specific polish that doesn't affect shared UI semantics.`,
|
|
116
122
|
}
|
|
@@ -622,6 +628,40 @@ server.registerTool(
|
|
|
622
628
|
}
|
|
623
629
|
);
|
|
624
630
|
|
|
631
|
+
// ── tool: openuispec_screenshot ──────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
server.registerTool(
|
|
634
|
+
"openuispec_screenshot",
|
|
635
|
+
{
|
|
636
|
+
description: "Take a screenshot of the generated web app at a specific route. Starts the Vite dev server automatically if needed (first call may take longer). Returns a PNG image for visual verification of generated UI. Requires puppeteer to be installed (npm install -g puppeteer).",
|
|
637
|
+
inputSchema: {
|
|
638
|
+
route: z.string().default("/").describe("Route path to navigate to, e.g. '/home', '/settings', '/posts/123'"),
|
|
639
|
+
viewport: z.object({
|
|
640
|
+
width: z.number().default(1280),
|
|
641
|
+
height: z.number().default(800),
|
|
642
|
+
}).optional().describe("Viewport dimensions. Defaults to 1280x800. Use {width: 375, height: 812} for mobile."),
|
|
643
|
+
theme: z.enum(["light", "dark"]).optional().describe("Force a color scheme via prefers-color-scheme emulation"),
|
|
644
|
+
wait_for: z.number().optional().default(1000).describe("Milliseconds to wait after page load before screenshotting (default 1000)"),
|
|
645
|
+
full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page instead of just the viewport"),
|
|
646
|
+
selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the full page"),
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
async ({ route, viewport, theme, wait_for, full_page, selector }) => {
|
|
650
|
+
try {
|
|
651
|
+
return await takeScreenshot(projectCwd, {
|
|
652
|
+
route,
|
|
653
|
+
viewport,
|
|
654
|
+
theme,
|
|
655
|
+
wait_for,
|
|
656
|
+
full_page,
|
|
657
|
+
selector,
|
|
658
|
+
});
|
|
659
|
+
} catch (err) {
|
|
660
|
+
return toolError(err);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
);
|
|
664
|
+
|
|
625
665
|
// ── start server ─────────────────────────────────────────────────────
|
|
626
666
|
|
|
627
667
|
export async function startMcpServer() {
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot tool — launches dev server + headless browser, captures pages.
|
|
3
|
+
*
|
|
4
|
+
* Both the Vite dev server and the Puppeteer browser are kept alive between
|
|
5
|
+
* calls and torn down when the MCP server process exits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { createServer, type AddressInfo } from "node:net";
|
|
12
|
+
import YAML from "yaml";
|
|
13
|
+
import { findProjectDir } from "../drift/index.js";
|
|
14
|
+
|
|
15
|
+
// ── types ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ScreenshotOptions {
|
|
18
|
+
route: string;
|
|
19
|
+
viewport?: { width: number; height: number };
|
|
20
|
+
theme?: "light" | "dark";
|
|
21
|
+
wait_for?: number;
|
|
22
|
+
full_page?: boolean;
|
|
23
|
+
selector?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScreenshotResult {
|
|
27
|
+
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
|
|
28
|
+
isError?: true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── free port finder ────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function findFreePort(): Promise<number> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const srv = createServer();
|
|
36
|
+
srv.listen(0, () => {
|
|
37
|
+
const port = (srv.address() as AddressInfo).port;
|
|
38
|
+
srv.close(() => resolve(port));
|
|
39
|
+
});
|
|
40
|
+
srv.on("error", reject);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── web app directory discovery ─────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function findWebAppDir(projectCwd: string): string {
|
|
47
|
+
const projectDir = findProjectDir(projectCwd);
|
|
48
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
49
|
+
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
50
|
+
const projectName = manifest.project?.name ?? "app";
|
|
51
|
+
|
|
52
|
+
// Check custom output_dir first
|
|
53
|
+
const customDir = manifest.generation?.output_dir?.web;
|
|
54
|
+
if (customDir) {
|
|
55
|
+
const resolved = resolve(projectDir, customDir);
|
|
56
|
+
if (existsSync(join(resolved, "package.json"))) return resolved;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Default: generated/web/<project-name>/
|
|
60
|
+
// Try from the project root (parent of openuispec/)
|
|
61
|
+
const projectRoot = resolve(projectDir, "..");
|
|
62
|
+
const defaultDir = join(projectRoot, "generated", "web", projectName);
|
|
63
|
+
if (existsSync(join(defaultDir, "package.json"))) return defaultDir;
|
|
64
|
+
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Web app not found. Checked:\n` +
|
|
67
|
+
(customDir ? ` - ${resolve(projectDir, customDir)}\n` : "") +
|
|
68
|
+
` - ${defaultDir}\n` +
|
|
69
|
+
`Generate the web target first, then try again.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── dev server manager ──────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
interface ServerInstance {
|
|
76
|
+
process: ChildProcess;
|
|
77
|
+
port: number;
|
|
78
|
+
url: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const servers = new Map<string, ServerInstance>();
|
|
82
|
+
|
|
83
|
+
function ensureDepsInstalled(webDir: string): void {
|
|
84
|
+
if (existsSync(join(webDir, "node_modules"))) return;
|
|
85
|
+
try {
|
|
86
|
+
execSync("npm install", { cwd: webDir, stdio: "pipe", timeout: 90_000 });
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new Error(`Failed to install web app dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function startDevServer(webDir: string): Promise<ServerInstance> {
|
|
93
|
+
const existing = servers.get(webDir);
|
|
94
|
+
if (existing) {
|
|
95
|
+
// Verify still running
|
|
96
|
+
if (existing.process.exitCode === null) return existing;
|
|
97
|
+
servers.delete(webDir);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ensureDepsInstalled(webDir);
|
|
101
|
+
const port = await findFreePort();
|
|
102
|
+
|
|
103
|
+
const child = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
|
|
104
|
+
cwd: webDir,
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Wait for "Local:" line from Vite
|
|
110
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
111
|
+
|
|
112
|
+
const url = await new Promise<string>((resolveUrl, reject) => {
|
|
113
|
+
const timeout = setTimeout(() => {
|
|
114
|
+
child.kill();
|
|
115
|
+
reject(new Error("Vite dev server failed to start within 30s"));
|
|
116
|
+
}, 30_000);
|
|
117
|
+
|
|
118
|
+
let output = "";
|
|
119
|
+
const onData = (chunk: Buffer) => {
|
|
120
|
+
output += chunk.toString();
|
|
121
|
+
const clean = stripAnsi(output);
|
|
122
|
+
const match = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
|
|
123
|
+
if (match) {
|
|
124
|
+
clearTimeout(timeout);
|
|
125
|
+
child.stdout?.off("data", onData);
|
|
126
|
+
child.stderr?.off("data", onData);
|
|
127
|
+
resolveUrl(match[1]);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
child.stdout?.on("data", onData);
|
|
131
|
+
child.stderr?.on("data", onData);
|
|
132
|
+
child.on("error", (err) => { clearTimeout(timeout); reject(err); });
|
|
133
|
+
child.on("exit", (code) => {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
if (!output.includes("Local:")) {
|
|
136
|
+
reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const instance: ServerInstance = { process: child, port, url };
|
|
142
|
+
servers.set(webDir, instance);
|
|
143
|
+
return instance;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── browser manager ─────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
let browserInstance: any = null;
|
|
149
|
+
|
|
150
|
+
async function getBrowser(): Promise<any> {
|
|
151
|
+
if (browserInstance?.connected) return browserInstance;
|
|
152
|
+
|
|
153
|
+
let puppeteer: any;
|
|
154
|
+
try {
|
|
155
|
+
puppeteer = await import("puppeteer");
|
|
156
|
+
} catch {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"puppeteer is not installed. Run:\n npm install -g puppeteer\n" +
|
|
159
|
+
"or add it to your project's devDependencies.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
browserInstance = await puppeteer.launch({
|
|
164
|
+
headless: true,
|
|
165
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
166
|
+
});
|
|
167
|
+
return browserInstance;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── screenshot capture ──────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
export async function takeScreenshot(
|
|
173
|
+
projectCwd: string,
|
|
174
|
+
options: ScreenshotOptions,
|
|
175
|
+
): Promise<ScreenshotResult> {
|
|
176
|
+
const {
|
|
177
|
+
route = "/",
|
|
178
|
+
viewport = { width: 1280, height: 800 },
|
|
179
|
+
theme,
|
|
180
|
+
wait_for = 1000,
|
|
181
|
+
full_page = false,
|
|
182
|
+
selector,
|
|
183
|
+
} = options;
|
|
184
|
+
|
|
185
|
+
// 1. Find and start
|
|
186
|
+
const webDir = findWebAppDir(projectCwd);
|
|
187
|
+
const server = await startDevServer(webDir);
|
|
188
|
+
const browser = await getBrowser();
|
|
189
|
+
|
|
190
|
+
// 2. Navigate
|
|
191
|
+
const page = await browser.newPage();
|
|
192
|
+
try {
|
|
193
|
+
await page.setViewport({ width: viewport.width, height: viewport.height });
|
|
194
|
+
|
|
195
|
+
if (theme) {
|
|
196
|
+
await page.emulateMediaFeatures([
|
|
197
|
+
{ name: "prefers-color-scheme", value: theme },
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const base = server.url.replace(/\/+$/, "");
|
|
202
|
+
const targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
|
|
203
|
+
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
204
|
+
|
|
205
|
+
if (wait_for > 0) {
|
|
206
|
+
await new Promise((r) => setTimeout(r, wait_for));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 3. Screenshot
|
|
210
|
+
let buffer: Buffer;
|
|
211
|
+
if (selector) {
|
|
212
|
+
const element = await page.$(selector);
|
|
213
|
+
if (!element) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
|
|
216
|
+
isError: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
buffer = await element.screenshot({ type: "png" });
|
|
220
|
+
} else {
|
|
221
|
+
buffer = await page.screenshot({ type: "png", fullPage: full_page });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const base64 = buffer.toString("base64");
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{ type: "image" as const, data: base64, mimeType: "image/png" },
|
|
229
|
+
{
|
|
230
|
+
type: "text" as const,
|
|
231
|
+
text: JSON.stringify({
|
|
232
|
+
route,
|
|
233
|
+
url: targetUrl,
|
|
234
|
+
viewport,
|
|
235
|
+
theme: theme ?? "default",
|
|
236
|
+
full_page,
|
|
237
|
+
selector: selector ?? null,
|
|
238
|
+
}, null, 2),
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
} finally {
|
|
243
|
+
await page.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── cleanup ─────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
export async function shutdownAll() {
|
|
250
|
+
for (const [, instance] of servers) {
|
|
251
|
+
try { instance.process.kill(); } catch { /* already dead */ }
|
|
252
|
+
}
|
|
253
|
+
servers.clear();
|
|
254
|
+
if (browserInstance) {
|
|
255
|
+
try { await browserInstance.close(); } catch { /* ignore */ }
|
|
256
|
+
browserInstance = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
process.on("exit", () => {
|
|
261
|
+
// Sync-only fallback for process exit
|
|
262
|
+
for (const [, instance] of servers) {
|
|
263
|
+
try { instance.process.kill(); } catch { /* already dead */ }
|
|
264
|
+
}
|
|
265
|
+
servers.clear();
|
|
266
|
+
});
|
|
267
|
+
process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
|
|
268
|
+
process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openuispec",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A semantic UI specification format for AI-native, platform-native app development",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"openuispec-mcp": "./mcp-server/index.ts"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "node --import tsx --test tests
|
|
31
|
+
"test": "node --import tsx --test tests/check.test.ts tests/configure-target.test.ts tests/drift-prepare.test.ts tests/init.test.ts tests/mcp-tools.test.ts tests/semantic-lint.test.ts tests/status.test.ts",
|
|
32
|
+
"test:screenshot": "node --import tsx --test tests/mcp-screenshot.test.ts",
|
|
33
|
+
"test:all": "node --import tsx --test tests/*.test.ts",
|
|
32
34
|
"validate": "tsx schema/validate.ts",
|
|
33
35
|
"validate:manifest": "tsx schema/validate.ts manifest",
|
|
34
36
|
"validate:tokens": "tsx schema/validate.ts tokens",
|
|
@@ -49,6 +51,9 @@
|
|
|
49
51
|
"tsx": "^4.19.4",
|
|
50
52
|
"yaml": "^2.7.1"
|
|
51
53
|
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"puppeteer": "^24.39.1"
|
|
56
|
+
},
|
|
52
57
|
"devDependencies": {
|
|
53
58
|
"@types/node": "^25.5.0",
|
|
54
59
|
"typescript": "^5.8.3"
|