openuispec 0.2.19 → 0.2.20
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/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
package/mcp-server/index.ts
DELETED
|
@@ -1,1041 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env -S npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* OpenUISpec MCP Server
|
|
4
|
-
*
|
|
5
|
-
* Exposes OpenUISpec CLI commands as MCP tools so AI assistants
|
|
6
|
-
* can call them directly instead of relying on CLAUDE.md instructions.
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* npx openuispec-mcp # stdio transport
|
|
10
|
-
* OPENUISPEC_PROJECT_DIR=/path npx openuispec-mcp # explicit project dir
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
-
import { z } from "zod";
|
|
16
|
-
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
17
|
-
import { join, dirname, relative, resolve } from "node:path";
|
|
18
|
-
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { SUPPORTED_TARGETS, findProjectDir, discoverSpecFiles, readProjectName, resolveOutputDir, stateFilePath, loadTargetDrift, createSnapshot } from "../drift/index.js";
|
|
20
|
-
import { buildPrepareResult } from "../prepare/index.js";
|
|
21
|
-
import { buildCheckResult } from "../check/index.js";
|
|
22
|
-
import { buildStatusResult } from "../status/index.js";
|
|
23
|
-
import { buildValidateResult } from "../schema/validate.js";
|
|
24
|
-
import YAML from "yaml";
|
|
25
|
-
import { takeScreenshot, takeScreenshotBatch } from "./screenshot.js";
|
|
26
|
-
import { takeAndroidScreenshot, takeAndroidScreenshotBatch } from "./screenshot-android.js";
|
|
27
|
-
import { takeIOSScreenshot, takeIOSScreenshotBatch } from "./screenshot-ios.js";
|
|
28
|
-
import { renderPreview } from "./preview.js";
|
|
29
|
-
|
|
30
|
-
// ── resolve project cwd ──────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
const projectCwd = process.env.OPENUISPEC_PROJECT_DIR || process.cwd();
|
|
33
|
-
|
|
34
|
-
// ── read package version ─────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
function getPackageVersion(): string {
|
|
37
|
-
try {
|
|
38
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
40
|
-
return pkg.version ?? "unknown";
|
|
41
|
-
} catch {
|
|
42
|
-
return "unknown";
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ── spec directory resolver ─────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
function resolveSpecDir(projectDir: string, manifest: any, key: string): string {
|
|
49
|
-
return resolve(projectDir, manifest.includes?.[key] ?? `./${key}/`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ── shared tool helpers ──────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
const targetSchema = z.enum(SUPPORTED_TARGETS).describe("Target platform");
|
|
55
|
-
|
|
56
|
-
function formatError(err: unknown): string {
|
|
57
|
-
return err instanceof Error ? err.message : String(err);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function toolResult(data: unknown, hint?: string): { content: { type: "text"; text: string }[] } {
|
|
61
|
-
const parts: { type: "text"; text: string }[] = [
|
|
62
|
-
{ type: "text" as const, text: JSON.stringify(data) },
|
|
63
|
-
];
|
|
64
|
-
if (hint) {
|
|
65
|
-
parts.push({ type: "text" as const, text: hint });
|
|
66
|
-
}
|
|
67
|
-
return { content: parts };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function toolError(err: unknown): { content: [{ type: "text"; text: string }]; isError: true } {
|
|
71
|
-
return { content: [{ type: "text" as const, text: `Error: ${formatError(err)}` }], isError: true };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function formatAuditValue(value: unknown): string {
|
|
75
|
-
if (typeof value === "string") return value;
|
|
76
|
-
return JSON.stringify(value);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function collectStateRoleAuditItems(node: unknown, prefix = ""): string[] {
|
|
80
|
-
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
81
|
-
return [];
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const items: string[] = [];
|
|
85
|
-
const record = node as Record<string, unknown>;
|
|
86
|
-
|
|
87
|
-
const states = record.states;
|
|
88
|
-
if (states && typeof states === "object" && !Array.isArray(states)) {
|
|
89
|
-
for (const [stateName, roles] of Object.entries(states as Record<string, unknown>)) {
|
|
90
|
-
if (!roles || typeof roles !== "object" || Array.isArray(roles)) continue;
|
|
91
|
-
for (const [roleName, roleValue] of Object.entries(roles as Record<string, unknown>)) {
|
|
92
|
-
const statePath = prefix ? `${prefix}.states.${stateName}.${roleName}` : `states.${stateName}.${roleName}`;
|
|
93
|
-
items.push(`${statePath} = ${formatAuditValue(roleValue)}`);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
for (const [key, value] of Object.entries(record)) {
|
|
99
|
-
if (key === "states" || !value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
100
|
-
const childPrefix = prefix ? `${prefix}.${key}` : key;
|
|
101
|
-
items.push(...collectStateRoleAuditItems(value, childPrefix));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return items;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ── create server ────────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
export const server = new McpServer(
|
|
110
|
-
{
|
|
111
|
-
name: "openuispec",
|
|
112
|
-
version: getPackageVersion(),
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
instructions: `OpenUISpec — semantic UI spec format. Spec files (YAML) are the single source of truth for all UI.
|
|
116
|
-
|
|
117
|
-
WORKFLOW — each tool response includes a next_tool hint, follow it:
|
|
118
|
-
1. openuispec_prepare(target) → get context + platform config (include_specs=true to embed content)
|
|
119
|
-
2. openuispec_read_specs(paths) → load spec content (omit paths for listing only)
|
|
120
|
-
3. Generate/update code
|
|
121
|
-
4. openuispec_check(target) → validate spec files (audit=true for review checklist, not code inspection)
|
|
122
|
-
5. Remind the user to baseline when satisfied: openuispec drift --snapshot --target <t>
|
|
123
|
-
Do not baseline on your own initiative — the user decides when output is accepted.
|
|
124
|
-
|
|
125
|
-
FOCUSED GETTERS (prefer for incremental edits): get_screen, get_contract, get_component, get_tokens, get_locale
|
|
126
|
-
SPEC AUTHORING: spec_types → spec_schema(type, summary?) → write YAML
|
|
127
|
-
PREVIEW: openuispec_preview(screen) → render spec as HTML with mock data, returns screenshot (no app needed)
|
|
128
|
-
SCREENSHOTS: screenshot (web), screenshot_android, screenshot_ios — single + batch variants
|
|
129
|
-
|
|
130
|
-
Skip only for purely non-UI requests.
|
|
131
|
-
|
|
132
|
-
When generating UI code:
|
|
133
|
-
1. Check anti_patterns in the prepare result — hard constraints on what NOT to produce
|
|
134
|
-
2. Check design_context — match the complexity level and personality description
|
|
135
|
-
3. Apply the AI Fingerprint Test: would a viewer immediately say "AI made this"? If yes, revise.`,
|
|
136
|
-
}
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
// ── tool: openuispec_prepare ─────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
server.registerTool(
|
|
142
|
-
"openuispec_prepare",
|
|
143
|
-
{
|
|
144
|
-
description: "Build AI-ready work bundle for a target platform. REQUIRED before any UI code generation. Returns spec context, platform config, semantic changes, and generation constraints.",
|
|
145
|
-
inputSchema: {
|
|
146
|
-
target: targetSchema,
|
|
147
|
-
include_specs: z.boolean().optional().default(false).describe("Embed all spec file contents in the response. Saves a separate read_specs call but increases response size."),
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
async ({ target, include_specs }) => {
|
|
151
|
-
try {
|
|
152
|
-
const result = buildPrepareResult(target, projectCwd, include_specs);
|
|
153
|
-
const baselinePending = result.baseline_status?.output_exists && !result.baseline_status?.snapshot_exists;
|
|
154
|
-
const baselineReminder = baselinePending
|
|
155
|
-
? " ⚠ Baseline pending — remind user to run `openuispec drift --snapshot --target " + target + "` when satisfied."
|
|
156
|
-
: "";
|
|
157
|
-
const sharedHint = result.shared_layers?.length
|
|
158
|
-
? ` ℹ ${result.shared_layers.length} shared layer(s) detected — check shared_layers for generation guidance.`
|
|
159
|
-
: "";
|
|
160
|
-
const hint = (include_specs
|
|
161
|
-
? "next_tool: openuispec_check (after generating code)"
|
|
162
|
-
: "next_tool: openuispec_read_specs (load spec contents for generation)") + baselineReminder + sharedHint;
|
|
163
|
-
return toolResult(result, hint);
|
|
164
|
-
} catch (err) {
|
|
165
|
-
return toolError(err);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
// ── tool: openuispec_check ───────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
function buildAuditChecklist(projectDir: string, target: string, screenFilter?: string[], contractFilter?: string[]): string {
|
|
173
|
-
const lines: string[] = [
|
|
174
|
-
"SPEC-DERIVED CHECKLIST — this is extracted from the spec files, NOT from generated code.",
|
|
175
|
-
"Use it as a guide when you manually review the generated code.",
|
|
176
|
-
"",
|
|
177
|
-
"For each item below, read the generated component/screen file,",
|
|
178
|
-
"find the code that implements it, and confirm the values match.",
|
|
179
|
-
"If you cannot find the implementation, it is a gap — fix it.",
|
|
180
|
-
"",
|
|
181
|
-
];
|
|
182
|
-
|
|
183
|
-
// Extract must_handle from contracts
|
|
184
|
-
const manifest = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
185
|
-
const contractsDir = resolveSpecDir(projectDir, manifest, "contracts");
|
|
186
|
-
|
|
187
|
-
if (existsSync(contractsDir)) {
|
|
188
|
-
lines.push("## Contract must_handle requirements");
|
|
189
|
-
for (const file of readdirSync(contractsDir).filter(f => f.endsWith(".yaml")).sort()) {
|
|
190
|
-
try {
|
|
191
|
-
const content = YAML.parse(readFileSync(join(contractsDir, file), "utf-8"));
|
|
192
|
-
const contractName = Object.keys(content)[0];
|
|
193
|
-
if (contractFilter && !contractFilter.includes(contractName)) continue;
|
|
194
|
-
const contract = content[contractName];
|
|
195
|
-
if (!contract?.variants) continue;
|
|
196
|
-
|
|
197
|
-
for (const [variantName, variant] of Object.entries(contract.variants as Record<string, any>)) {
|
|
198
|
-
const mustHandle = variant?.generation?.must_handle;
|
|
199
|
-
if (mustHandle?.length) {
|
|
200
|
-
lines.push(`\n### ${contractName}.${variantName}`);
|
|
201
|
-
for (const item of mustHandle) {
|
|
202
|
-
lines.push(`- [ ] ${item}`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const stateRoleItems = collectStateRoleAuditItems(variant?.tokens);
|
|
207
|
-
if (stateRoleItems.length) {
|
|
208
|
-
if (!mustHandle?.length) {
|
|
209
|
-
lines.push(`\n### ${contractName}.${variantName}`);
|
|
210
|
-
}
|
|
211
|
-
lines.push(`- [ ] Explicit state-role tokens are implemented for ${contractName}.${variantName}`);
|
|
212
|
-
for (const item of stateRoleItems) {
|
|
213
|
-
lines.push(`- [ ] ${item}`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Top-level generation.must_handle
|
|
219
|
-
const topMustHandle = contract?.generation?.must_handle;
|
|
220
|
-
if (topMustHandle?.length) {
|
|
221
|
-
lines.push(`\n### ${contractName} (global)`);
|
|
222
|
-
for (const item of topMustHandle) {
|
|
223
|
-
lines.push(`- [ ] ${item}`);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const topLevelStateRoleItems = collectStateRoleAuditItems(contract?.tokens);
|
|
228
|
-
if (topLevelStateRoleItems.length) {
|
|
229
|
-
lines.push(`\n### ${contractName} (state-role tokens)`);
|
|
230
|
-
for (const item of topLevelStateRoleItems) {
|
|
231
|
-
lines.push(`- [ ] ${item}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
} catch { /* skip unparseable files */ }
|
|
235
|
-
}
|
|
236
|
-
lines.push("");
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Extract screens and their sections
|
|
240
|
-
const screensDir = resolveSpecDir(projectDir, manifest, "screens");
|
|
241
|
-
if (existsSync(screensDir)) {
|
|
242
|
-
lines.push("## Screens — verify all sections exist in generated code");
|
|
243
|
-
for (const file of readdirSync(screensDir).filter(f => f.endsWith(".yaml")).sort()) {
|
|
244
|
-
try {
|
|
245
|
-
const content = YAML.parse(readFileSync(join(screensDir, file), "utf-8"));
|
|
246
|
-
const screenName = Object.keys(content)[0];
|
|
247
|
-
if (screenFilter && !screenFilter.includes(screenName)) continue;
|
|
248
|
-
const screen = content[screenName];
|
|
249
|
-
if (screen?.status === "stub") continue;
|
|
250
|
-
|
|
251
|
-
const sections: string[] = [];
|
|
252
|
-
const collectSections = (node: any, prefix = "") => {
|
|
253
|
-
if (!node || typeof node !== "object") return;
|
|
254
|
-
if (node.contract) sections.push(`${prefix}${node.contract}${node.variant ? `.${node.variant}` : ""}`);
|
|
255
|
-
if (node.sections) {
|
|
256
|
-
for (const [key, child] of Object.entries(node.sections)) {
|
|
257
|
-
collectSections(child, `${prefix}${key}/`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
if (node.children && Array.isArray(node.children)) {
|
|
261
|
-
for (const child of node.children) {
|
|
262
|
-
collectSections(child, prefix);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
collectSections(screen?.layout);
|
|
267
|
-
|
|
268
|
-
if (sections.length > 0) {
|
|
269
|
-
lines.push(`\n### ${screenName} (${file})`);
|
|
270
|
-
for (const section of sections) {
|
|
271
|
-
lines.push(`- [ ] ${section}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Adaptive layout
|
|
276
|
-
if (screen?.layout?.adaptive || screen?.adaptive) {
|
|
277
|
-
lines.push(`- [ ] Adaptive breakpoints implemented`);
|
|
278
|
-
}
|
|
279
|
-
} catch { /* skip */ }
|
|
280
|
-
}
|
|
281
|
-
lines.push("");
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Locale keys count
|
|
285
|
-
const localesDir = resolveSpecDir(projectDir, manifest, "locales");
|
|
286
|
-
if (existsSync(localesDir)) {
|
|
287
|
-
const localeFiles = readdirSync(localesDir).filter(f => f.endsWith(".json"));
|
|
288
|
-
if (localeFiles.length > 0) {
|
|
289
|
-
lines.push("## Locales — verify all locale files are wired");
|
|
290
|
-
for (const file of localeFiles) {
|
|
291
|
-
try {
|
|
292
|
-
const keys = Object.keys(JSON.parse(readFileSync(join(localesDir, file), "utf-8")));
|
|
293
|
-
lines.push(`- [ ] ${file}: ${keys.length} keys loaded at runtime`);
|
|
294
|
-
} catch { /* skip */ }
|
|
295
|
-
}
|
|
296
|
-
lines.push("");
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Platform-specific checks
|
|
301
|
-
const platformDir = resolveSpecDir(projectDir, manifest, "platform");
|
|
302
|
-
const platformPath = join(platformDir, `${target}.yaml`);
|
|
303
|
-
if (existsSync(platformPath)) {
|
|
304
|
-
try {
|
|
305
|
-
const platformDoc = YAML.parse(readFileSync(platformPath, "utf-8"));
|
|
306
|
-
const platformDef = platformDoc?.[target];
|
|
307
|
-
if (platformDef?.generation) {
|
|
308
|
-
lines.push("## Platform generation requirements");
|
|
309
|
-
const gen = platformDef.generation;
|
|
310
|
-
if (gen.architecture) lines.push(`- [ ] Architecture: ${gen.architecture}`);
|
|
311
|
-
if (gen.naming) lines.push(`- [ ] Naming convention: ${gen.naming}`);
|
|
312
|
-
if (gen.css) lines.push(`- [ ] CSS framework: ${gen.css}`);
|
|
313
|
-
}
|
|
314
|
-
} catch { /* skip */ }
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
lines.push("FOR EACH UNCHECKED ITEM: Read the generated file, search for the implementation,");
|
|
318
|
-
lines.push("and either confirm it matches or fix it. Do not mark items as 'intentionally skipped'");
|
|
319
|
-
lines.push("unless the user explicitly requested to skip them.");
|
|
320
|
-
|
|
321
|
-
return lines.join("\n");
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
server.registerTool(
|
|
325
|
-
"openuispec_check",
|
|
326
|
-
{
|
|
327
|
-
description: "Validate spec files (schema + semantic lint) and check prepare readiness. Does NOT inspect generated code. With audit=true, returns a spec-derived checklist of must_handle items, must_avoid anti-patterns, screen sections, and locale files — use it as a guide when YOU manually review the generated code. design_quality_score and audit_findings are included when audit=true.",
|
|
328
|
-
inputSchema: {
|
|
329
|
-
target: targetSchema,
|
|
330
|
-
audit: z.boolean().optional().default(false).describe("Include the full audit checklist. Omit for a compact pass/fail summary."),
|
|
331
|
-
screens: z.array(z.string()).optional().describe("Screen names to audit (e.g. ['home_feed']). Requires audit=true."),
|
|
332
|
-
contracts: z.array(z.string()).optional().describe("Contract names to audit (e.g. ['action_trigger']). Requires audit=true."),
|
|
333
|
-
},
|
|
334
|
-
},
|
|
335
|
-
async ({ target, audit: includeAudit, screens, contracts }) => {
|
|
336
|
-
try {
|
|
337
|
-
const result = buildCheckResult(target, projectCwd, includeAudit);
|
|
338
|
-
const totalErrors = result.validation.total_errors + result.semantic.total_errors;
|
|
339
|
-
const passing = totalErrors === 0 && result.prepare.ready;
|
|
340
|
-
|
|
341
|
-
// bootstrap mode = no snapshot exists yet
|
|
342
|
-
const baselineHint = result.prepare.mode === "bootstrap"
|
|
343
|
-
? `⚠ BASELINE PENDING: Remind the user to run \`openuispec drift --snapshot --target ${target}\` when satisfied with the output.`
|
|
344
|
-
: "";
|
|
345
|
-
|
|
346
|
-
if (passing && !includeAudit) {
|
|
347
|
-
const compact = {
|
|
348
|
-
target,
|
|
349
|
-
status: "PASS",
|
|
350
|
-
validation_errors: 0,
|
|
351
|
-
semantic_errors: 0,
|
|
352
|
-
prepare: { mode: result.prepare.mode, ready: true },
|
|
353
|
-
};
|
|
354
|
-
return toolResult(compact, baselineHint || `Remind the user to baseline: openuispec drift --snapshot --target ${target}`);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const projectDir = findProjectDir(projectCwd);
|
|
358
|
-
const hints: string[] = [JSON.stringify(result)];
|
|
359
|
-
|
|
360
|
-
if (includeAudit) {
|
|
361
|
-
const screenFilter = screens && screens.length > 0 ? screens : undefined;
|
|
362
|
-
const contractFilter = contracts && contracts.length > 0 ? contracts : undefined;
|
|
363
|
-
hints.push(buildAuditChecklist(projectDir, target, screenFilter, contractFilter));
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (includeAudit && result.audit) {
|
|
367
|
-
hints.push(`DESIGN QUALITY SCORE: ${result.audit.score}/100 (${result.audit.errors} errors, ${result.audit.warnings} warnings)`);
|
|
368
|
-
if (!result.audit.passed && result.audit.threshold > 0) {
|
|
369
|
-
hints.push(`SCORE BELOW THRESHOLD: ${result.audit.score} < ${result.audit.threshold}`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
if (baselineHint) hints.push(baselineHint);
|
|
374
|
-
hints.push(passing ? "next_tool: openuispec_drift --snapshot (to create/update baseline)" : "Fix validation errors, then re-run openuispec_check.");
|
|
375
|
-
|
|
376
|
-
return { content: hints.map(text => ({ type: "text" as const, text })) };
|
|
377
|
-
} catch (err) {
|
|
378
|
-
return toolError(err);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
// ── tool: openuispec_status ──────────────────────────────────────────
|
|
384
|
-
|
|
385
|
-
server.registerTool(
|
|
386
|
-
"openuispec_status",
|
|
387
|
-
{
|
|
388
|
-
description: "Show cross-target status summary: baseline, drift, and recommended next steps for all configured targets. Good starting point to understand project state.",
|
|
389
|
-
},
|
|
390
|
-
async () => {
|
|
391
|
-
try {
|
|
392
|
-
return toolResult(buildStatusResult(projectCwd), "next_tool: openuispec_prepare for any target that is 'behind' or 'needs generation'");
|
|
393
|
-
} catch (err) {
|
|
394
|
-
return toolError(err);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
// ── tool: openuispec_validate ────────────────────────────────────────
|
|
400
|
-
|
|
401
|
-
server.registerTool(
|
|
402
|
-
"openuispec_validate",
|
|
403
|
-
{
|
|
404
|
-
description: "Validate spec files against JSON schemas. Returns validation errors grouped by type (manifest, tokens, screens, flows, platform, locales, contracts, components, semantic).",
|
|
405
|
-
inputSchema: {
|
|
406
|
-
groups: z
|
|
407
|
-
.array(z.enum(["manifest", "tokens", "screens", "flows", "platform", "locales", "contracts", "components", "semantic"]))
|
|
408
|
-
.optional()
|
|
409
|
-
.describe("Specific groups to validate. If omitted, validates all groups."),
|
|
410
|
-
},
|
|
411
|
-
},
|
|
412
|
-
async ({ groups }) => {
|
|
413
|
-
try {
|
|
414
|
-
const result = buildValidateResult(groups, projectCwd);
|
|
415
|
-
return toolResult(result, "next_tool: openuispec_check (for full validation + prepare readiness)");
|
|
416
|
-
} catch (err) {
|
|
417
|
-
return toolError(err);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
);
|
|
421
|
-
|
|
422
|
-
// ── tool: openuispec_read_specs ───────────────────────────────────────
|
|
423
|
-
|
|
424
|
-
server.registerTool(
|
|
425
|
-
"openuispec_read_specs",
|
|
426
|
-
{
|
|
427
|
-
description: "Read spec file contents. Pass specific paths to load those files. If no paths given, returns a listing of all spec files (path + category, no content) — use that to pick which files to load.",
|
|
428
|
-
inputSchema: {
|
|
429
|
-
paths: z
|
|
430
|
-
.array(z.string())
|
|
431
|
-
.optional()
|
|
432
|
-
.describe("Spec file paths to read (relative, e.g. 'screens/home.yaml'). If omitted, returns listing only."),
|
|
433
|
-
},
|
|
434
|
-
},
|
|
435
|
-
async ({ paths }) => {
|
|
436
|
-
try {
|
|
437
|
-
const projectDir = findProjectDir(projectCwd);
|
|
438
|
-
const allFiles = discoverSpecFiles(projectDir);
|
|
439
|
-
|
|
440
|
-
if (!paths || paths.length === 0) {
|
|
441
|
-
// Listing mode — paths + categories, no content
|
|
442
|
-
const listing = allFiles.map((f) => {
|
|
443
|
-
const rel = relative(projectDir, f);
|
|
444
|
-
const dir = dirname(rel);
|
|
445
|
-
const category = rel === "openuispec.yaml" ? "manifest" : (dir || "other");
|
|
446
|
-
return { path: rel, category };
|
|
447
|
-
});
|
|
448
|
-
return toolResult(listing, "next_tool: openuispec_read_specs with specific paths to load content");
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const filesToRead = allFiles.filter((f) => {
|
|
452
|
-
const rel = relative(projectDir, f);
|
|
453
|
-
return paths.some((p) => rel === p || rel.endsWith(p));
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
const contents = filesToRead.map((f) => ({
|
|
457
|
-
path: relative(projectDir, f),
|
|
458
|
-
content: readFileSync(f, "utf-8"),
|
|
459
|
-
}));
|
|
460
|
-
|
|
461
|
-
return toolResult(contents, "next_tool: generate/update code, then openuispec_check");
|
|
462
|
-
} catch (err) {
|
|
463
|
-
return toolError(err);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
// ── tool: openuispec_drift ───────────────────────────────────────────
|
|
469
|
-
|
|
470
|
-
server.registerTool(
|
|
471
|
-
"openuispec_drift",
|
|
472
|
-
{
|
|
473
|
-
description: "Detect spec drift since last snapshot, or create a new snapshot. Shows which spec files changed, were added, or removed. Use explain for property-level changes. Use snapshot=true after generation to create/update the baseline.",
|
|
474
|
-
inputSchema: {
|
|
475
|
-
target: targetSchema,
|
|
476
|
-
explain: z.boolean().optional().default(false).describe("Include semantic explanation of changes"),
|
|
477
|
-
snapshot: z.boolean().optional().default(false).describe("Create a new snapshot (baseline) instead of checking drift. Use after code generation is complete and verified."),
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
async ({ target, explain, snapshot: doSnapshot }) => {
|
|
481
|
-
try {
|
|
482
|
-
if (doSnapshot) {
|
|
483
|
-
const result = createSnapshot(projectCwd, target);
|
|
484
|
-
return toolResult(result, "Baseline created. next_tool: openuispec_status (to verify all targets)");
|
|
485
|
-
}
|
|
486
|
-
const { result } = loadTargetDrift(projectCwd, target, false, explain);
|
|
487
|
-
const d = result.drift;
|
|
488
|
-
const hasDrift = d.changed.length > 0 || d.added.length > 0 || d.removed.length > 0;
|
|
489
|
-
const hint = hasDrift
|
|
490
|
-
? "next_tool: openuispec_prepare (to build work bundle for pending changes)"
|
|
491
|
-
: "No drift detected. Target is up to date.";
|
|
492
|
-
return toolResult(result, hint);
|
|
493
|
-
} catch (err) {
|
|
494
|
-
return toolError(err);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
);
|
|
498
|
-
|
|
499
|
-
// ── schema type catalog ─────────────────────────────────────────
|
|
500
|
-
|
|
501
|
-
const SCHEMA_CATALOG: Record<string, { file: string; title: string; description: string }> = {
|
|
502
|
-
manifest: { file: "openuispec.schema.json", title: "Root Manifest", description: "Root manifest (openuispec.yaml): project info, includes, generation targets, data model, API endpoints, formatters, mappers" },
|
|
503
|
-
screen: { file: "screen.schema.json", title: "Screen", description: "Screen composition: layout, sections, navigation, surfaces, data/state bindings, adaptive breakpoints" },
|
|
504
|
-
flow: { file: "flow.schema.json", title: "Flow", description: "Navigation flow definitions: steps, transitions, guards, and entry points" },
|
|
505
|
-
platform: { file: "platform.schema.json", title: "Platform", description: "Platform-specific generation config: architecture, naming, CSS framework, component mapping" },
|
|
506
|
-
contract: { file: "contract.schema.json", title: "Contract", description: "Built-in UI contract definitions: variants, props, must_handle states, generation hints" },
|
|
507
|
-
"custom-contract":{ file: "custom-contract.schema.json", title: "Custom Contract", description: "User-defined UI contract definitions (x_ prefixed)" },
|
|
508
|
-
component: { file: "component.schema.json", title: "Component", description: "Reusable composition of contracts with named slots, states, variants, and layout" },
|
|
509
|
-
locale: { file: "locale.schema.json", title: "Locale", description: "Locale translation files: flat key-value string maps" },
|
|
510
|
-
"tokens/color": { file: "tokens/color.schema.json", title: "Color Tokens", description: "Color tokens: brand, surface, text, semantic, border groups with HSL ranges and contrast" },
|
|
511
|
-
"tokens/typography": { file: "tokens/typography.schema.json", title: "Typography Tokens", description: "Typography tokens: font families, sizes, weights, line heights, letter spacing" },
|
|
512
|
-
"tokens/spacing": { file: "tokens/spacing.schema.json", title: "Spacing Tokens", description: "Spacing tokens: named spacing scale values" },
|
|
513
|
-
"tokens/elevation": { file: "tokens/elevation.schema.json", title: "Elevation Tokens", description: "Elevation tokens: shadow definitions per level" },
|
|
514
|
-
"tokens/motion": { file: "tokens/motion.schema.json", title: "Motion Tokens", description: "Motion tokens: animation duration and easing curves" },
|
|
515
|
-
"tokens/layout": { file: "tokens/layout.schema.json", title: "Layout Tokens", description: "Layout tokens: radii, breakpoints, max widths, grid columns" },
|
|
516
|
-
"tokens/themes": { file: "tokens/themes.schema.json", title: "Theme Tokens", description: "Theme definitions: token overrides per theme (e.g. dark mode)" },
|
|
517
|
-
"tokens/icons": { file: "tokens/icons.schema.json", title: "Icon Tokens", description: "Icon tokens: icon set, size scale, default size" },
|
|
518
|
-
};
|
|
519
|
-
|
|
520
|
-
// ── tool: openuispec_spec_types ──────────────────────────────────
|
|
521
|
-
|
|
522
|
-
server.registerTool(
|
|
523
|
-
"openuispec_spec_types",
|
|
524
|
-
{
|
|
525
|
-
description: "List all available OpenUISpec spec types with brief descriptions. Use this to discover what kinds of spec files can be created and what schema format each one follows. Call openuispec_spec_schema with a specific type to get the full JSON schema.",
|
|
526
|
-
},
|
|
527
|
-
async () => {
|
|
528
|
-
const types = Object.entries(SCHEMA_CATALOG).map(([type, info]) => ({
|
|
529
|
-
type,
|
|
530
|
-
title: info.title,
|
|
531
|
-
description: info.description,
|
|
532
|
-
}));
|
|
533
|
-
return toolResult(types, "next_tool: openuispec_spec_schema(type) for the full schema of a specific type");
|
|
534
|
-
}
|
|
535
|
-
);
|
|
536
|
-
|
|
537
|
-
// ── tool: openuispec_spec_schema ─────────────────────────────────
|
|
538
|
-
|
|
539
|
-
server.registerTool(
|
|
540
|
-
"openuispec_spec_schema",
|
|
541
|
-
{
|
|
542
|
-
description: "Get the JSON schema for a specific OpenUISpec spec type. Returns the complete schema definition so you know the exact format when creating or editing spec files.",
|
|
543
|
-
inputSchema: {
|
|
544
|
-
type: z.string().describe("Spec type (e.g. 'screen', 'tokens/color', 'contract'). Use openuispec_spec_types to list all."),
|
|
545
|
-
summary: z.boolean().optional().default(false).describe("Return only top-level property names and types instead of the full schema. Useful for a quick overview."),
|
|
546
|
-
},
|
|
547
|
-
},
|
|
548
|
-
async ({ type, summary }) => {
|
|
549
|
-
const entry = SCHEMA_CATALOG[type];
|
|
550
|
-
if (!entry) {
|
|
551
|
-
return toolError(`Unknown spec type "${type}". Call openuispec_spec_types to see available types.`);
|
|
552
|
-
}
|
|
553
|
-
try {
|
|
554
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
555
|
-
const schemaPath = join(__dirname, "..", "schema", entry.file);
|
|
556
|
-
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
557
|
-
|
|
558
|
-
if (summary) {
|
|
559
|
-
// Extract top-level properties summary
|
|
560
|
-
const props = schema.properties ?? schema.patternProperties ?? {};
|
|
561
|
-
const topLevel: Record<string, string> = {};
|
|
562
|
-
for (const [key, val] of Object.entries(props)) {
|
|
563
|
-
const v = val as any;
|
|
564
|
-
topLevel[key] = v.type ?? (v.$ref ? `ref:${v.$ref}` : "object");
|
|
565
|
-
}
|
|
566
|
-
return toolResult({ type, title: entry.title, required: schema.required ?? [], properties: topLevel },
|
|
567
|
-
"Use summary=false for the full schema when creating/editing spec files.");
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
return toolResult({ type, title: entry.title, schema });
|
|
571
|
-
} catch (err) {
|
|
572
|
-
return toolError(err);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
);
|
|
576
|
-
|
|
577
|
-
// ── tool: openuispec_get_screen ──────────────────────────────────────
|
|
578
|
-
|
|
579
|
-
server.registerTool(
|
|
580
|
-
"openuispec_get_screen",
|
|
581
|
-
{
|
|
582
|
-
description: "Get the parsed content of a single screen spec file. Faster than read_specs when you only need one screen.",
|
|
583
|
-
inputSchema: {
|
|
584
|
-
name: z.string().describe("Screen name, e.g. 'home_feed' (matches filename without .yaml)"),
|
|
585
|
-
},
|
|
586
|
-
},
|
|
587
|
-
async ({ name }) => {
|
|
588
|
-
try {
|
|
589
|
-
const projectDir = findProjectDir(projectCwd);
|
|
590
|
-
const manifest = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
591
|
-
const screensDir = resolveSpecDir(projectDir, manifest, "screens");
|
|
592
|
-
const filePath = join(screensDir, `${name}.yaml`);
|
|
593
|
-
if (!existsSync(filePath)) {
|
|
594
|
-
return toolError(`Screen "${name}" not found. Expected file: ${filePath}`);
|
|
595
|
-
}
|
|
596
|
-
const content = readFileSync(filePath, "utf-8");
|
|
597
|
-
return toolResult({ name, path: relative(projectDir, filePath), content });
|
|
598
|
-
} catch (err) {
|
|
599
|
-
return toolError(err);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
);
|
|
603
|
-
|
|
604
|
-
// ── tool: openuispec_get_contract ───────────────────────────────────
|
|
605
|
-
|
|
606
|
-
server.registerTool(
|
|
607
|
-
"openuispec_get_contract",
|
|
608
|
-
{
|
|
609
|
-
description: "Get a single contract spec, optionally filtered to one variant. Faster than read_specs when you only need one contract.",
|
|
610
|
-
inputSchema: {
|
|
611
|
-
name: z.string().describe("Contract name, e.g. 'action_trigger'"),
|
|
612
|
-
variant: z.string().optional().describe("Optional variant name, e.g. 'fab'. If given, returns only that variant's definition."),
|
|
613
|
-
},
|
|
614
|
-
},
|
|
615
|
-
async ({ name, variant }) => {
|
|
616
|
-
try {
|
|
617
|
-
const projectDir = findProjectDir(projectCwd);
|
|
618
|
-
const manifest = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
619
|
-
const contractsDir = resolveSpecDir(projectDir, manifest, "contracts");
|
|
620
|
-
|
|
621
|
-
if (!existsSync(contractsDir)) {
|
|
622
|
-
return toolError(`Contracts directory not found: ${contractsDir}`);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Scan contract files for the matching contract key
|
|
626
|
-
for (const file of readdirSync(contractsDir).filter(f => f.endsWith(".yaml")).sort()) {
|
|
627
|
-
const filePath = join(contractsDir, file);
|
|
628
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
629
|
-
const content = YAML.parse(raw);
|
|
630
|
-
const contractName = Object.keys(content)[0];
|
|
631
|
-
if (contractName !== name) continue;
|
|
632
|
-
|
|
633
|
-
if (variant) {
|
|
634
|
-
const contract = content[contractName];
|
|
635
|
-
const variantDef = contract?.variants?.[variant];
|
|
636
|
-
if (!variantDef) {
|
|
637
|
-
return toolError(`Variant "${variant}" not found in contract "${name}". Available variants: ${Object.keys(contract?.variants ?? {}).join(", ")}`);
|
|
638
|
-
}
|
|
639
|
-
return toolResult({ name, variant, definition: variantDef });
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
return toolResult({ name, path: relative(projectDir, filePath), content: raw });
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
return toolError(`Contract "${name}" not found in ${contractsDir}`);
|
|
646
|
-
} catch (err) {
|
|
647
|
-
return toolError(err);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
// ── tool: openuispec_get_component ──────────────────────────────────
|
|
653
|
-
|
|
654
|
-
server.registerTool(
|
|
655
|
-
"openuispec_get_component",
|
|
656
|
-
{
|
|
657
|
-
description: "Get a single component spec. Components are reusable compositions of contracts with named slots.",
|
|
658
|
-
inputSchema: {
|
|
659
|
-
name: z.string().describe("Component name, e.g. 'media_player'"),
|
|
660
|
-
variant: z.string().optional().describe("Optional variant name. If given, returns only that variant's definition."),
|
|
661
|
-
},
|
|
662
|
-
},
|
|
663
|
-
async ({ name, variant }) => {
|
|
664
|
-
try {
|
|
665
|
-
const projectDir = findProjectDir(projectCwd);
|
|
666
|
-
const manifest = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
667
|
-
const componentsDir = resolveSpecDir(projectDir, manifest, "components");
|
|
668
|
-
|
|
669
|
-
if (!existsSync(componentsDir)) {
|
|
670
|
-
return toolError(`Components directory not found: ${componentsDir}`);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
for (const file of readdirSync(componentsDir).filter(f => f.endsWith(".yaml")).sort()) {
|
|
674
|
-
const filePath = join(componentsDir, file);
|
|
675
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
676
|
-
const content = YAML.parse(raw);
|
|
677
|
-
const componentName = Object.keys(content)[0];
|
|
678
|
-
if (componentName !== name) continue;
|
|
679
|
-
|
|
680
|
-
if (variant) {
|
|
681
|
-
const component = content[componentName];
|
|
682
|
-
const variantDef = component?.variants?.[variant];
|
|
683
|
-
if (!variantDef) {
|
|
684
|
-
return toolError(`Variant "${variant}" not found in component "${name}". Available variants: ${Object.keys(component?.variants ?? {}).join(", ")}`);
|
|
685
|
-
}
|
|
686
|
-
return toolResult({ name, variant, definition: variantDef });
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
return toolResult({ name, path: relative(projectDir, filePath), content: raw });
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
return toolError(`Component "${name}" not found in ${componentsDir}`);
|
|
693
|
-
} catch (err) {
|
|
694
|
-
return toolError(err);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
);
|
|
698
|
-
|
|
699
|
-
// ── tool: openuispec_get_tokens ─────────────────────────────────────
|
|
700
|
-
|
|
701
|
-
server.registerTool(
|
|
702
|
-
"openuispec_get_tokens",
|
|
703
|
-
{
|
|
704
|
-
description: "Get tokens for a specific category (color, typography, spacing, elevation, motion, layout, themes, icons). Faster than read_specs when you only need one token file.",
|
|
705
|
-
inputSchema: {
|
|
706
|
-
category: z.string().describe("Token category, e.g. 'color', 'typography', 'spacing', 'elevation', 'motion', 'layout', 'themes', 'icons'"),
|
|
707
|
-
},
|
|
708
|
-
},
|
|
709
|
-
async ({ category }) => {
|
|
710
|
-
try {
|
|
711
|
-
const projectDir = findProjectDir(projectCwd);
|
|
712
|
-
const manifest = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
713
|
-
const tokensDir = resolveSpecDir(projectDir, manifest, "tokens");
|
|
714
|
-
|
|
715
|
-
if (!existsSync(tokensDir)) {
|
|
716
|
-
return toolError(`Tokens directory not found: ${tokensDir}`);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// Try exact match first, then scan for files containing the category name
|
|
720
|
-
const candidates = [
|
|
721
|
-
`${category}.yaml`,
|
|
722
|
-
`${category}.yml`,
|
|
723
|
-
];
|
|
724
|
-
|
|
725
|
-
for (const candidate of candidates) {
|
|
726
|
-
const filePath = join(tokensDir, candidate);
|
|
727
|
-
if (existsSync(filePath)) {
|
|
728
|
-
const content = readFileSync(filePath, "utf-8");
|
|
729
|
-
return toolResult({ category, path: relative(projectDir, filePath), content });
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// List available token files for helpful error
|
|
734
|
-
const available = readdirSync(tokensDir)
|
|
735
|
-
.filter(f => f.endsWith(".yaml") || f.endsWith(".yml"))
|
|
736
|
-
.map(f => f.replace(/\.ya?ml$/, ""));
|
|
737
|
-
return toolError(`Token category "${category}" not found. Available: ${available.join(", ")}`);
|
|
738
|
-
} catch (err) {
|
|
739
|
-
return toolError(err);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
// ── locale key lookup (supports both flat dotted keys and nested objects) ──
|
|
745
|
-
|
|
746
|
-
function lookupLocaleKey(content: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
|
|
747
|
-
// 1. Try flat (literal) key first: { "nav.tasks": "Tasks" }
|
|
748
|
-
if (key in content) {
|
|
749
|
-
return { found: true, value: content[key] };
|
|
750
|
-
}
|
|
751
|
-
// 2. Try nested path: { nav: { tasks: "Tasks" } }
|
|
752
|
-
const parts = key.split(".");
|
|
753
|
-
let current: unknown = content;
|
|
754
|
-
for (const part of parts) {
|
|
755
|
-
if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current)) {
|
|
756
|
-
return { found: false, value: undefined };
|
|
757
|
-
}
|
|
758
|
-
current = (current as Record<string, unknown>)[part];
|
|
759
|
-
}
|
|
760
|
-
return current !== undefined ? { found: true, value: current } : { found: false, value: undefined };
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// ── tool: openuispec_get_locale ─────────────────────────────────────
|
|
764
|
-
|
|
765
|
-
server.registerTool(
|
|
766
|
-
"openuispec_get_locale",
|
|
767
|
-
{
|
|
768
|
-
description: "Get a single locale file, optionally filtered to specific keys. Faster than read_specs when you only need one locale or specific translation keys.",
|
|
769
|
-
inputSchema: {
|
|
770
|
-
locale: z.string().describe("Locale code, e.g. 'en', 'ru'"),
|
|
771
|
-
keys: z.array(z.string()).optional().describe("Optional list of keys to filter to, e.g. ['nav.home', 'nav.create']. If omitted, returns the full locale file."),
|
|
772
|
-
},
|
|
773
|
-
},
|
|
774
|
-
async ({ locale, keys }) => {
|
|
775
|
-
try {
|
|
776
|
-
const projectDir = findProjectDir(projectCwd);
|
|
777
|
-
const manifest = YAML.parse(readFileSync(join(projectDir, "openuispec.yaml"), "utf-8"));
|
|
778
|
-
const localesDir = resolveSpecDir(projectDir, manifest, "locales");
|
|
779
|
-
const filePath = join(localesDir, `${locale}.json`);
|
|
780
|
-
|
|
781
|
-
if (!existsSync(filePath)) {
|
|
782
|
-
if (existsSync(localesDir)) {
|
|
783
|
-
const available = readdirSync(localesDir)
|
|
784
|
-
.filter(f => f.endsWith(".json"))
|
|
785
|
-
.map(f => f.replace(/\.json$/, ""));
|
|
786
|
-
return toolError(`Locale "${locale}" not found. Available: ${available.join(", ")}`);
|
|
787
|
-
}
|
|
788
|
-
return toolError(`Locales directory not found: ${localesDir}`);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
792
|
-
const content = JSON.parse(raw);
|
|
793
|
-
|
|
794
|
-
if (keys && keys.length > 0) {
|
|
795
|
-
const filtered: Record<string, unknown> = {};
|
|
796
|
-
for (const key of keys) {
|
|
797
|
-
const { found, value } = lookupLocaleKey(content, key);
|
|
798
|
-
if (found) filtered[key] = value;
|
|
799
|
-
}
|
|
800
|
-
return toolResult({ locale, path: relative(projectDir, filePath), content: filtered });
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
return toolResult({ locale, path: relative(projectDir, filePath), content });
|
|
804
|
-
} catch (err) {
|
|
805
|
-
return toolError(err);
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
);
|
|
809
|
-
|
|
810
|
-
// ── tool: openuispec_screenshot ──────────────────────────────────────
|
|
811
|
-
|
|
812
|
-
server.registerTool(
|
|
813
|
-
"openuispec_screenshot",
|
|
814
|
-
{
|
|
815
|
-
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).",
|
|
816
|
-
inputSchema: {
|
|
817
|
-
route: z.string().default("/").describe("Route path to navigate to, e.g. '/home', '/settings', '/posts/123'"),
|
|
818
|
-
viewport: z.object({
|
|
819
|
-
width: z.number().default(1280),
|
|
820
|
-
height: z.number().default(800),
|
|
821
|
-
}).optional().describe("Viewport dimensions. Defaults to 1280x800. Use {width: 375, height: 812} for mobile."),
|
|
822
|
-
scale: z.number().optional().default(2).describe("Device pixel ratio used for capture. Higher values produce sharper screenshots (default 2)."),
|
|
823
|
-
theme: z.enum(["light", "dark"]).optional().describe("Force a color scheme via prefers-color-scheme emulation"),
|
|
824
|
-
wait_for: z.number().optional().default(1000).describe("Milliseconds to wait after page load before screenshotting (default 1000)"),
|
|
825
|
-
full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page instead of just the viewport"),
|
|
826
|
-
selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the full page"),
|
|
827
|
-
output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to web app root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
|
|
828
|
-
init_script: z.string().optional().describe("JavaScript to run before the page renders. Passed to the app via ?__ous_init=<base64> query param. The app's bootstrapper decodes and executes it — use for auth injection, role switching, or session setup."),
|
|
829
|
-
},
|
|
830
|
-
},
|
|
831
|
-
async ({ route, viewport, scale, theme, wait_for, full_page, selector, output_dir, init_script }) => {
|
|
832
|
-
try {
|
|
833
|
-
return await takeScreenshot(projectCwd, {
|
|
834
|
-
route,
|
|
835
|
-
viewport,
|
|
836
|
-
scale,
|
|
837
|
-
theme,
|
|
838
|
-
wait_for,
|
|
839
|
-
full_page,
|
|
840
|
-
selector,
|
|
841
|
-
output_dir,
|
|
842
|
-
init_script,
|
|
843
|
-
});
|
|
844
|
-
} catch (err) {
|
|
845
|
-
return toolError(err);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
);
|
|
849
|
-
|
|
850
|
-
// ── tool: openuispec_screenshot_android ───────────────────────────────
|
|
851
|
-
|
|
852
|
-
server.registerTool(
|
|
853
|
-
"openuispec_screenshot_android",
|
|
854
|
-
{
|
|
855
|
-
description: "Take a screenshot of an Android app on an emulator. Builds the APK, installs it, launches the app, and captures via adb screencap. Shows the real app with navigation, images, and themes. Requires a running Android emulator. Works with any Android project — use project_dir to point directly at a project, or uses OpenUISpec manifest discovery if available.",
|
|
856
|
-
inputSchema: {
|
|
857
|
-
screen: z.string().optional().describe("Screen name for metadata and filename (e.g. 'home_feed')."),
|
|
858
|
-
route: z.string().optional().describe("Deep link URI to navigate to a specific screen (e.g. 'myapp://profile/123'). If omitted, launches the main activity."),
|
|
859
|
-
nav: z.array(z.string()).optional().describe("UI navigation steps — tap elements by visible text in order (e.g. ['Profile', 'Settings']). Executed after the app launches and loads."),
|
|
860
|
-
theme: z.enum(["light", "dark"]).optional().describe("Force light or dark mode via system UI mode"),
|
|
861
|
-
wait_for: z.number().optional().default(3000).describe("Milliseconds to wait after launch for content to load (default 3000)"),
|
|
862
|
-
output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to Android project root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
|
|
863
|
-
project_dir: z.string().optional().describe("Direct path to the Android project root (containing gradlew). Skips OpenUISpec manifest lookup. Use this for standalone Android projects."),
|
|
864
|
-
module: z.string().optional().describe("App module name (e.g. 'app', 'mobile'). If omitted, auto-detects by scanning settings.gradle for the module with com.android.application plugin."),
|
|
865
|
-
},
|
|
866
|
-
},
|
|
867
|
-
async ({ screen, route, nav, theme, wait_for, output_dir, project_dir, module }) => {
|
|
868
|
-
try {
|
|
869
|
-
return await takeAndroidScreenshot(projectCwd, { screen, route, nav, theme, wait_for, output_dir, project_dir, module });
|
|
870
|
-
} catch (err) {
|
|
871
|
-
return toolError(err);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
);
|
|
875
|
-
|
|
876
|
-
// ── tool: openuispec_screenshot_ios ───────────────────────────────────
|
|
877
|
-
|
|
878
|
-
server.registerTool(
|
|
879
|
-
"openuispec_screenshot_ios",
|
|
880
|
-
{
|
|
881
|
-
description: "Take a screenshot of an iOS app on a Simulator. Builds with xcodebuild, installs on simulator, launches the app, and captures via xcrun simctl. Shows the real app with navigation, images, and themes. Requires Xcode. Works with any iOS project — use project_dir to point directly at a project, or uses OpenUISpec manifest discovery if available.",
|
|
882
|
-
inputSchema: {
|
|
883
|
-
screen: z.string().optional().describe("Screen name for metadata and filename (e.g. 'settings')."),
|
|
884
|
-
device: z.string().optional().describe("Simulator device name (e.g. 'iPhone 15', 'iPad Pro 11-inch (M4)'). If omitted, uses any booted iPhone or the first available one."),
|
|
885
|
-
nav: z.array(z.string()).optional().describe("UI navigation steps — tap elements by visible text in order (e.g. ['Profile', 'Settings']). Executed after the app launches and loads."),
|
|
886
|
-
theme: z.enum(["light", "dark"]).optional().describe("Force light or dark appearance on the simulator"),
|
|
887
|
-
wait_for: z.number().optional().default(3000).describe("Milliseconds to wait after launch for content to load (default 3000)"),
|
|
888
|
-
output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to iOS project root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
|
|
889
|
-
project_dir: z.string().optional().describe("Direct path to the iOS project root (containing .xcodeproj/.xcworkspace). Skips OpenUISpec manifest lookup. Use this for standalone iOS projects."),
|
|
890
|
-
scheme: z.string().optional().describe("Xcode scheme name to build. If omitted, auto-detects from xcshareddata/xcschemes or uses the project name."),
|
|
891
|
-
bundle_id: z.string().optional().describe("App bundle identifier (e.g. 'com.example.myapp'). If omitted, auto-detects from project.pbxproj."),
|
|
892
|
-
},
|
|
893
|
-
},
|
|
894
|
-
async ({ screen, device, nav, theme, wait_for, output_dir, project_dir, scheme, bundle_id }) => {
|
|
895
|
-
try {
|
|
896
|
-
return await takeIOSScreenshot(projectCwd, { screen, device, nav, theme, wait_for, output_dir, project_dir, scheme, bundle_id });
|
|
897
|
-
} catch (err) {
|
|
898
|
-
return toolError(err);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
);
|
|
902
|
-
|
|
903
|
-
// ── tool: openuispec_screenshot_web_batch ──────────────────────────────
|
|
904
|
-
|
|
905
|
-
const webBatchCaptureSchema = z.object({
|
|
906
|
-
screen: z.string().describe("Screen name for metadata and filename"),
|
|
907
|
-
route: z.string().describe("Route path (e.g. '/home', '/settings')"),
|
|
908
|
-
selector: z.string().optional().describe("CSS selector to screenshot a specific element"),
|
|
909
|
-
full_page: z.boolean().optional().describe("Capture full scrollable page"),
|
|
910
|
-
wait_for: z.number().optional().describe("Per-capture wait time in ms"),
|
|
911
|
-
init_script: z.string().optional().describe("Per-capture init script (overrides shared init_script for this capture)"),
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
server.registerTool(
|
|
915
|
-
"openuispec_screenshot_web_batch",
|
|
916
|
-
{
|
|
917
|
-
description: "Take multiple web screenshots in a single server session. Starts the dev server once, then captures all routes in sequence. Much faster than calling screenshot for each route individually.",
|
|
918
|
-
inputSchema: {
|
|
919
|
-
captures: z.array(webBatchCaptureSchema).describe("Array of captures — each with screen name and route"),
|
|
920
|
-
viewport: z.object({ width: z.number().default(1280), height: z.number().default(800) }).optional().describe("Viewport dimensions for all captures"),
|
|
921
|
-
scale: z.number().optional().default(2).describe("Device pixel ratio for all captures (default 2)"),
|
|
922
|
-
theme: z.enum(["light", "dark"]).optional().describe("Force color scheme for all captures"),
|
|
923
|
-
output_dir: z.string().optional().describe("Directory to save all PNGs (relative to web app root)"),
|
|
924
|
-
init_script: z.string().optional().describe("Shared init script for all captures. Passed via ?__ous_init=<base64>. Per-capture init_script overrides this."),
|
|
925
|
-
},
|
|
926
|
-
},
|
|
927
|
-
async ({ captures, viewport, scale, theme, output_dir, init_script }) => {
|
|
928
|
-
try {
|
|
929
|
-
return await takeScreenshotBatch(projectCwd, { captures, viewport, scale, theme, output_dir, init_script });
|
|
930
|
-
} catch (err) {
|
|
931
|
-
return toolError(err);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
);
|
|
935
|
-
|
|
936
|
-
// ── tool: openuispec_screenshot_android_batch ─────────────────────────
|
|
937
|
-
|
|
938
|
-
const androidBatchCaptureSchema = z.object({
|
|
939
|
-
screen: z.string().describe("Screen name for metadata and filename"),
|
|
940
|
-
route: z.string().optional().describe("Deep link URI to launch"),
|
|
941
|
-
nav: z.array(z.string()).optional().describe("UI tap steps after launch"),
|
|
942
|
-
wait_for: z.number().optional().describe("Per-capture wait time in ms"),
|
|
943
|
-
});
|
|
944
|
-
|
|
945
|
-
server.registerTool(
|
|
946
|
-
"openuispec_screenshot_android_batch",
|
|
947
|
-
{
|
|
948
|
-
description: "Take multiple Android screenshots in a single build+install cycle. Builds the APK once, installs once, then captures each screen in sequence via deep links or UI navigation. Much faster than calling screenshot_android for each screen individually.",
|
|
949
|
-
inputSchema: {
|
|
950
|
-
captures: z.array(androidBatchCaptureSchema).describe("Array of captures — each with screen name and optional route/nav"),
|
|
951
|
-
theme: z.enum(["light", "dark"]).optional().describe("Force light or dark mode for all captures"),
|
|
952
|
-
output_dir: z.string().optional().describe("Directory to save all PNGs (relative to Android project root)"),
|
|
953
|
-
project_dir: z.string().optional().describe("Direct path to Android project root"),
|
|
954
|
-
module: z.string().optional().describe("App module name (default: auto-detect)"),
|
|
955
|
-
},
|
|
956
|
-
},
|
|
957
|
-
async ({ captures, theme, output_dir, project_dir, module }) => {
|
|
958
|
-
try {
|
|
959
|
-
return await takeAndroidScreenshotBatch(projectCwd, { captures, theme, output_dir, project_dir, module });
|
|
960
|
-
} catch (err) {
|
|
961
|
-
return toolError(err);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
);
|
|
965
|
-
|
|
966
|
-
// ── tool: openuispec_screenshot_ios_batch ──────────────────────────────
|
|
967
|
-
|
|
968
|
-
const iosBatchCaptureSchema = z.object({
|
|
969
|
-
screen: z.string().describe("Screen name for metadata and filename"),
|
|
970
|
-
nav: z.array(z.string()).optional().describe("UI tap steps after launch"),
|
|
971
|
-
wait_for: z.number().optional().describe("Per-capture wait time in ms"),
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
server.registerTool(
|
|
975
|
-
"openuispec_screenshot_ios_batch",
|
|
976
|
-
{
|
|
977
|
-
description: "Take multiple iOS screenshots in a single build+install cycle. Builds the app once, then captures each screen — no-nav screens via simctl, nav screens batched into a single XCUITest run. Much faster than calling screenshot_ios for each screen individually.",
|
|
978
|
-
inputSchema: {
|
|
979
|
-
captures: z.array(iosBatchCaptureSchema).describe("Array of captures — each with screen name and optional nav steps"),
|
|
980
|
-
device: z.string().optional().describe("Simulator device name"),
|
|
981
|
-
theme: z.enum(["light", "dark"]).optional().describe("Force light or dark appearance for all captures"),
|
|
982
|
-
output_dir: z.string().optional().describe("Directory to save all PNGs (relative to iOS project root)"),
|
|
983
|
-
project_dir: z.string().optional().describe("Direct path to iOS project root"),
|
|
984
|
-
scheme: z.string().optional().describe("Xcode scheme name"),
|
|
985
|
-
bundle_id: z.string().optional().describe("App bundle identifier"),
|
|
986
|
-
},
|
|
987
|
-
},
|
|
988
|
-
async ({ captures, device, theme, output_dir, project_dir, scheme, bundle_id }) => {
|
|
989
|
-
try {
|
|
990
|
-
return await takeIOSScreenshotBatch(projectCwd, { captures, device, theme, output_dir, project_dir, scheme, bundle_id });
|
|
991
|
-
} catch (err) {
|
|
992
|
-
return toolError(err);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
);
|
|
996
|
-
|
|
997
|
-
// ── tool: openuispec_preview ────────────────────────────────────────────
|
|
998
|
-
|
|
999
|
-
server.registerTool(
|
|
1000
|
-
"openuispec_preview",
|
|
1001
|
-
{
|
|
1002
|
-
description: "Render a screen spec as an HTML preview with mock data and return a screenshot. Uses token values, locale strings, and contract-to-HTML mapping to produce a visual approximation without generating a full app. Mock data should be placed in openuispec/mock/<screen>.yaml.",
|
|
1003
|
-
inputSchema: {
|
|
1004
|
-
screen: z.string().describe("Screen name (e.g. 'home', 'settings', 'task_detail')"),
|
|
1005
|
-
size_class: z.enum(["compact", "regular", "expanded"]).optional().default("compact").describe("Adaptive size class — compact (phone), regular (tablet), expanded (desktop)"),
|
|
1006
|
-
theme: z.enum(["light", "dark"]).optional().default("light").describe("Color theme"),
|
|
1007
|
-
locale: z.string().optional().default("en").describe("Locale code for i18n strings"),
|
|
1008
|
-
viewport: z.object({
|
|
1009
|
-
width: z.number(),
|
|
1010
|
-
height: z.number(),
|
|
1011
|
-
}).optional().describe("Custom viewport size (overrides size_class default)"),
|
|
1012
|
-
include_html: z.boolean().optional().default(false).describe("Also return the rendered HTML string in the response"),
|
|
1013
|
-
},
|
|
1014
|
-
},
|
|
1015
|
-
async ({ screen, size_class, theme, locale, viewport, include_html }) => {
|
|
1016
|
-
try {
|
|
1017
|
-
return await renderPreview(projectCwd, { screen, size_class, theme, locale, viewport, include_html });
|
|
1018
|
-
} catch (err) {
|
|
1019
|
-
return toolError(err);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
);
|
|
1023
|
-
|
|
1024
|
-
// ── start server ─────────────────────────────────────────────────────
|
|
1025
|
-
|
|
1026
|
-
export async function startMcpServer() {
|
|
1027
|
-
const transport = new StdioServerTransport();
|
|
1028
|
-
await server.connect(transport);
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
// Direct execution (npx openuispec-mcp)
|
|
1032
|
-
const isDirectRun =
|
|
1033
|
-
process.argv[1]?.endsWith("mcp-server/index.ts") ||
|
|
1034
|
-
process.argv[1]?.endsWith("mcp-server/index.js");
|
|
1035
|
-
|
|
1036
|
-
if (isDirectRun) {
|
|
1037
|
-
startMcpServer().catch((err) => {
|
|
1038
|
-
console.error("Failed to start OpenUISpec MCP server:", err);
|
|
1039
|
-
process.exit(1);
|
|
1040
|
-
});
|
|
1041
|
-
}
|