openuispec 0.2.19 → 0.2.21

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.
Files changed (38) hide show
  1. package/dist/check/audit.js +392 -0
  2. package/dist/check/index.js +216 -0
  3. package/dist/cli/configure-target.js +391 -0
  4. package/dist/cli/index.js +510 -0
  5. package/dist/cli/init.js +964 -0
  6. package/dist/drift/index.js +903 -0
  7. package/dist/mcp-server/index.js +888 -0
  8. package/dist/mcp-server/preview-render.js +1761 -0
  9. package/dist/mcp-server/preview.js +229 -0
  10. package/dist/mcp-server/screenshot-android.js +458 -0
  11. package/dist/mcp-server/screenshot-ios.js +639 -0
  12. package/dist/mcp-server/screenshot-shared.js +185 -0
  13. package/dist/mcp-server/screenshot.js +469 -0
  14. package/dist/prepare/index.js +1216 -0
  15. package/dist/runtime/package-paths.js +33 -0
  16. package/dist/schema/semantic-lint.js +564 -0
  17. package/dist/schema/validate.js +689 -0
  18. package/dist/status/index.js +194 -0
  19. package/package.json +13 -14
  20. package/check/audit.ts +0 -426
  21. package/check/index.ts +0 -320
  22. package/cli/configure-target.ts +0 -523
  23. package/cli/index.ts +0 -537
  24. package/cli/init.ts +0 -1253
  25. package/drift/index.ts +0 -1165
  26. package/mcp-server/index.ts +0 -1041
  27. package/mcp-server/preview-render.ts +0 -1922
  28. package/mcp-server/preview.ts +0 -292
  29. package/mcp-server/screenshot-android.ts +0 -621
  30. package/mcp-server/screenshot-ios.ts +0 -753
  31. package/mcp-server/screenshot-shared.ts +0 -237
  32. package/mcp-server/screenshot.ts +0 -563
  33. package/prepare/index.ts +0 -1530
  34. package/schema/semantic-lint.ts +0 -692
  35. package/schema/validate.ts +0 -870
  36. package/scripts/regenerate-previews.ts +0 -136
  37. package/scripts/take-all-screenshots.ts +0 -507
  38. package/status/index.ts +0 -275
@@ -0,0 +1,964 @@
1
+ /**
2
+ * openuispec init — interactive project scaffolding
3
+ *
4
+ * Creates folder structure, manifest, and AI assistant rules
5
+ * (CLAUDE.md / AGENTS.md) so AI tools track spec changes properly.
6
+ */
7
+ import { createInterface } from "node:readline/promises";
8
+ import { stdin, stdout } from "node:process";
9
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, appendFileSync, unlinkSync, } from "node:fs";
10
+ import { join, relative } from "node:path";
11
+ import { SUPPORTED_TARGETS, isSupportedTarget } from "../drift/index.js";
12
+ import { readPackageVersion, resolvePackagePath } from "../runtime/package-paths.js";
13
+ export function listInitOptions() {
14
+ const defaults = collectDefaults();
15
+ return {
16
+ command: "init",
17
+ note: "After init, run `openuispec configure-target <target> --list-options` for each target to get stack choices.",
18
+ questions: [
19
+ {
20
+ key: "name",
21
+ prompt: "Project name",
22
+ type: "text",
23
+ default: defaults.name,
24
+ },
25
+ {
26
+ key: "spec_dir",
27
+ prompt: "Spec directory",
28
+ type: "text",
29
+ default: defaults.specDir,
30
+ },
31
+ {
32
+ key: "targets",
33
+ prompt: "Which platforms?",
34
+ type: "list",
35
+ default: defaults.targets,
36
+ options: [...SUPPORTED_TARGETS],
37
+ },
38
+ {
39
+ key: "with_api",
40
+ prompt: "Will this spec declare API endpoints?",
41
+ type: "yes_no",
42
+ default: defaults.withApi,
43
+ },
44
+ {
45
+ key: "backend_path",
46
+ prompt: "Backend folder path relative to openuispec.yaml",
47
+ type: "text",
48
+ default: defaults.backendPath ?? "../backend/",
49
+ },
50
+ {
51
+ key: "configure_targets",
52
+ prompt: "Configure target stacks now?",
53
+ type: "yes_no",
54
+ default: defaults.configureTargets,
55
+ },
56
+ {
57
+ key: "with_shared",
58
+ prompt: "Does the project share code between platforms (e.g. KMP commonMain)?",
59
+ type: "yes_no",
60
+ default: false,
61
+ },
62
+ ],
63
+ configure_targets_note: "If configure_targets is true, use `openuispec configure-target <target> --list-options` for each target after init to present stack choices to the user.",
64
+ shared_layer_note: "If with_shared is true, add shared layer config to generation.shared in the manifest. Each shared layer needs: name, platforms (subset of targets), language, root (path relative to openuispec.yaml), tracks (spec categories: manifest, contracts, flows, screens, tokens, platform, locales), and scope (what code belongs there). Also add generation.structure entries for each target to define where platform-specific UI code goes and its scope.",
65
+ };
66
+ }
67
+ // ── prompts ──────────────────────────────────────────────────────────
68
+ export async function ask(rl, question, fallback) {
69
+ const suffix = fallback ? ` (${fallback})` : "";
70
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
71
+ return answer || fallback || "";
72
+ }
73
+ async function askList(rl, question, options, defaults) {
74
+ const defaultStr = defaults.join(", ");
75
+ const raw = (await rl.question(`${question} [${options.join(", ")}] (${defaultStr}): `)).trim();
76
+ if (!raw)
77
+ return defaults;
78
+ return raw
79
+ .split(",")
80
+ .map((s) => s.trim().toLowerCase())
81
+ .filter((s) => options.includes(s));
82
+ }
83
+ export async function askChoice(rl, question, options, fallback) {
84
+ const answer = (await rl.question(`${question} [${options.join(", ")}] (${fallback}): `))
85
+ .trim()
86
+ .toLowerCase();
87
+ if (!answer)
88
+ return fallback;
89
+ return options.includes(answer) ? answer : fallback;
90
+ }
91
+ async function askYesNo(rl, question, fallback) {
92
+ const answer = await askChoice(rl, question, ["yes", "no"], fallback ? "yes" : "no");
93
+ return answer === "yes";
94
+ }
95
+ // ── scaffold ─────────────────────────────────────────────────────────
96
+ function ensureDir(path) {
97
+ mkdirSync(path, { recursive: true });
98
+ }
99
+ function writeIfMissing(path, content, quiet = false) {
100
+ if (existsSync(path)) {
101
+ if (!quiet)
102
+ console.log(` skip ${relative(process.cwd(), path)} (exists)`);
103
+ return false;
104
+ }
105
+ writeFileSync(path, content);
106
+ if (!quiet)
107
+ console.log(` create ${relative(process.cwd(), path)}`);
108
+ return true;
109
+ }
110
+ // ── version ─────────────────────────────────────────────────────────
111
+ const RULES_START_MARKER = "<!-- openuispec-rules-start -->";
112
+ const RULES_END_MARKER = "<!-- openuispec-rules-end -->";
113
+ function getPackageVersion() {
114
+ return readPackageVersion(import.meta.url);
115
+ }
116
+ // ── templates ────────────────────────────────────────────────────────
117
+ function manifestTemplate(name, targets, options) {
118
+ const targetList = targets.join(", ");
119
+ const outputLines = targets
120
+ .map((t) => {
121
+ if (t === "ios")
122
+ return ` ios: { language: swift, framework: swiftui, min_version: "17.0" }`;
123
+ if (t === "android")
124
+ return ` android: { language: kotlin, framework: compose, min_sdk: 26 }`;
125
+ if (t === "web")
126
+ return ` web: { language: typescript, framework: react, bundler: vite }`;
127
+ return ` ${t}: {}`;
128
+ })
129
+ .join("\n");
130
+ function yamlPathsBlock(paths) {
131
+ const entries = Object.entries(paths);
132
+ return entries.length > 0
133
+ ? `\n paths:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
134
+ : "";
135
+ }
136
+ let sharedBlock = "";
137
+ if (options.sharedLayers.length > 0) {
138
+ const layers = options.sharedLayers.map((layer) => {
139
+ const tracksLine = layer.tracks.length > 0
140
+ ? `\n tracks: [${layer.tracks.join(", ")}]`
141
+ : "";
142
+ return ` ${layer.name}:
143
+ platforms: [${layer.platforms.join(", ")}]
144
+ language: ${layer.language}
145
+ root: "${layer.root}"
146
+ scope: "${layer.scope}"${tracksLine}${yamlPathsBlock(layer.paths)}`;
147
+ }).join("\n");
148
+ sharedBlock = ` shared:\n${layers}\n`;
149
+ }
150
+ let structureBlock = "";
151
+ if (options.structures.length > 0) {
152
+ const entries = options.structures.map((s) => {
153
+ const scopeLine = s.scope ? `\n scope: "${s.scope}"` : "";
154
+ return ` ${s.target}:
155
+ root: "${s.root}"${scopeLine}${yamlPathsBlock(s.paths)}`;
156
+ }).join("\n");
157
+ structureBlock = ` structure:\n${entries}\n`;
158
+ }
159
+ return `# ${name} — OpenUISpec v0.2
160
+ spec_version: "0.2"
161
+
162
+ project:
163
+ name: "${name}"
164
+ description: ""
165
+
166
+ includes:
167
+ tokens: "./tokens/"
168
+ contracts: "./contracts/"
169
+ components: "./components/"
170
+ screens: "./screens/"
171
+ flows: "./flows/"
172
+ platform: "./platform/"
173
+ locales: "./locales/"
174
+
175
+ i18n:
176
+ default_locale: "en"
177
+ supported_locales: [en]
178
+ fallback_strategy: "default"
179
+
180
+ generation:
181
+ targets: [${targetList}]
182
+ # extra_rules: # Optional: project-wide AI authoring conventions
183
+ # - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
184
+ # output_dir: # Optional: map targets to code directories
185
+ # ios: "../ios-app/" # relative to this file
186
+ # android: "../android-app/"
187
+ # web: "../web-ui/"
188
+ ${options.withApi ? ` code_roots:
189
+ backend: "${options.backendPath}" # Required when api.endpoints are declared
190
+ ` : ""} output_format:
191
+ ${outputLines}
192
+ ${sharedBlock}${structureBlock}
193
+ data_model: {}
194
+
195
+ api:
196
+ base_url: "/api/v1"
197
+ auth: "bearer_token"
198
+ endpoints: {}
199
+ `;
200
+ }
201
+ function specReadmeTemplate(name, targets) {
202
+ const targetList = targets.join(", ");
203
+ return `# ${name} — OpenUISpec
204
+
205
+ This directory contains the **OpenUISpec** semantic UI specification for **${name}**.
206
+
207
+ **Start here:** read \`openuispec.yaml\` — it defines the project structure, data model, API endpoints, and generation targets (**${targetList}**).
208
+
209
+ ## Directory structure
210
+
211
+ | Directory | Contents |
212
+ |-----------|----------|
213
+ | \`tokens/\` | Design tokens — colors, typography, spacing, elevation, motion, icons, themes |
214
+ | \`screens/\` | Screen definitions — one YAML file per screen |
215
+ | \`flows/\` | Navigation flows — multi-step user journeys |
216
+ | \`contracts/\` | Component contracts — standard extensions and custom (\`x_\` prefixed) |
217
+ | \`components/\` | Reusable component compositions — contract slot compositions with states and variants |
218
+ | \`platform/\` | Platform overrides — per-target (iOS, Android, Web) behaviors |
219
+ | \`locales/\` | Localization — i18n strings (JSON, ICU MessageFormat) |
220
+
221
+ ## IMPORTANT — Read the specification before working with spec files
222
+
223
+ The spec format, file schemas, and generation rules are defined in the installed \`openuispec\` package.
224
+ You MUST read these reference files before creating, editing, or generating from any spec file.
225
+ Do NOT guess the file format — skipping this step will produce invalid YAML that fails validation.
226
+
227
+ **Find the package in this order:**
228
+ 1. \`node_modules/openuispec/\` (project dependency)
229
+ 2. Run \`npm root -g\` → \`<prefix>/openuispec/\` (global install)
230
+ 3. Online: \`https://openuispec.rsteam.uz/llms-full.txt\` (if not installed)
231
+
232
+ **Reference files inside the package (read in this order):**
233
+ 1. \`README.md\` — schema tables, file format reference, root wrapper keys
234
+ 2. \`spec/openuispec-v0.2.md\` — full specification (contracts, layout, expressions, etc.)
235
+ 3. \`examples/taskflow/openuispec/\` — complete working example with all file types
236
+ 4. \`schema/\` — JSON Schemas for validation
237
+
238
+ ## MCP Tools (recommended for AI assistants)
239
+
240
+ When the openuispec MCP server is configured, AI assistants should use these tools instead of CLI commands:
241
+
242
+ | Tool | When to use |
243
+ |------|-------------|
244
+ | \`openuispec_spec_types\` | Discover available spec types and their descriptions. |
245
+ | \`openuispec_spec_schema\` | Get the full JSON schema for a specific spec type — exact structure, required fields, allowed values. |
246
+ | \`openuispec_prepare\` | **Before any UI code generation.** Returns spec context, platform config, and constraints. |
247
+ | \`openuispec_read_specs\` | Load spec file contents — the authoritative source for tokens, screens, contracts. |
248
+ | \`openuispec_check\` | After editing spec files. Validates schema + semantics + readiness. Optional \`screens\`/\`contracts\` params scope the audit. |
249
+ | \`openuispec_validate\` | Schema-only validation, optionally by group. |
250
+ | \`openuispec_drift\` | Detect spec changes since last snapshot. |
251
+ | \`openuispec_status\` | To understand cross-target state (baselines, drift, next steps). |
252
+ | \`openuispec_get_screen\` | Get a single screen spec by name — faster than \`read_specs\` for targeted edits. |
253
+ | \`openuispec_get_contract\` | Get a single contract spec, optionally filtered to one variant. |
254
+ | \`openuispec_get_tokens\` | Get tokens for a specific category (color, typography, spacing, etc.). |
255
+ | \`openuispec_get_locale\` | Get a single locale file, optionally filtered to specific keys. |
256
+ | \`openuispec_screenshot\` | Screenshot the web app at a route via headless browser (requires \`playwright\`). |
257
+ | \`openuispec_screenshot_android\` | Screenshot Android app on emulator — works with any project via \`project_dir\`. |
258
+ | \`openuispec_screenshot_ios\` | Screenshot iOS app on Simulator via XCUITest — works with any project via \`project_dir\`. |
259
+
260
+ ## CLI commands
261
+
262
+ \`\`\`bash
263
+ # Workflow
264
+ openuispec validate # Validate spec files against schemas
265
+ openuispec validate semantic # Run semantic cross-reference linting
266
+ openuispec configure-target ${targets[0]} [--defaults] # Configure target stack; --defaults stays unconfirmed
267
+ openuispec status # Show cross-target baseline/drift status
268
+ openuispec drift --target ${targets[0]} --explain # Explain semantic spec drift
269
+ openuispec prepare --target ${targets[0]} # Build the target work bundle
270
+ openuispec drift --snapshot --target ${targets[0]} # Snapshot current state + git baseline after target output exists
271
+
272
+ # Spec access
273
+ openuispec read-specs [paths...] # Read spec file contents as JSON
274
+ openuispec get-screen <name> # Get a single screen spec
275
+ openuispec get-contract <name> [--variant v] # Get a contract spec
276
+ openuispec get-tokens <category> # Get tokens for a category
277
+ openuispec get-locale <locale> [--keys k1,k2] # Get a locale file
278
+ openuispec spec-types # List available spec types
279
+ openuispec spec-schema <type> # Get JSON schema for a spec type
280
+
281
+ # Screenshots
282
+ openuispec screenshot --route /home --theme dark --output-dir screenshots
283
+ openuispec screenshot-android --screen home --project-dir /path/to/android
284
+ openuispec screenshot-ios --screen home --project-dir /path/to/ios --scheme MyApp
285
+ \`\`\`
286
+
287
+ The target work bundle has two modes:
288
+ - \`bootstrap\` when no snapshot exists yet, for first-time generation
289
+ - \`update\` after a snapshot exists, for drift-based target updates
290
+
291
+ If target stack values were written with \`--defaults\`, treat them as unconfirmed. Before generating code, ask the user to confirm or change the stack and run \`openuispec configure-target <target>\` without \`--defaults\`.
292
+
293
+ ## Learn more
294
+
295
+ Docs: https://openuispec.rsteam.uz
296
+ `;
297
+ }
298
+ function aiRulesBlock(specDir, targets) {
299
+ const targetList = targets.map((t) => `"${t}"`).join(", ");
300
+ const version = getPackageVersion();
301
+ return `
302
+ ${RULES_START_MARKER}
303
+ <!-- openuispec-rules-version: ${version} -->
304
+ # OpenUISpec Rules
305
+
306
+ STOP — before writing ANY UI code you MUST call an OpenUISpec tool first.
307
+ No exceptions. Spec files are the single source of truth. Targets: ${targetList}
308
+
309
+ ## Workflow
310
+
311
+ **Full generation** (new screens, multi-platform changes, first-time setup):
312
+ 1. \`openuispec_prepare(target, include_specs: true)\` — returns everything
313
+ 2. Generate code strictly from the spec contents returned
314
+ 3. \`openuispec_check(target, audit: true)\` — returns a checklist, verify your code against it
315
+ 4. Fix gaps. Repeat 2–3 until audit passes.
316
+
317
+ **Incremental edits** (one screen, one token, one locale key):
318
+ 1. Use a focused getter: \`get_screen\`, \`get_contract\`, \`get_component\`, \`get_tokens\`, \`get_locale\`
319
+ 2. Edit code based on the spec returned
320
+ 3. \`openuispec_check(target)\` — validate
321
+
322
+ **Creating new spec files:**
323
+ 1. \`openuispec_spec_types\` → \`openuispec_spec_schema(type)\` → write YAML following the schema
324
+
325
+ **Other tools:**
326
+ - \`openuispec_status\` — cross-target summary
327
+ - \`openuispec_drift(explain: true)\` — what changed since last baseline
328
+ - \`openuispec_validate\` — schema-only validation
329
+ - \`openuispec_screenshot\` / \`screenshot_android\` / \`screenshot_ios\` — visual verification
330
+ - \`openuispec_preview(screen)\` — render spec as HTML, no running app needed
331
+
332
+ **CLI fallback** (when MCP is unavailable): \`openuispec <command> --json\` — same names, with dashes.
333
+
334
+ ## Spec-first vs platform-first
335
+
336
+ **Spec-first** (use the workflow above):
337
+ - Screen structure, navigation, fields, actions, validation, data binding
338
+ - Token, variant, contract, flow, or localization changes
339
+ - Changes affecting multiple platforms
340
+
341
+ **Platform-first** (skip spec tools):
342
+ - Platform-only polish (iOS-only animation, web-only CSS tweak)
343
+ - Bug fixes that don't alter shared behavior
344
+
345
+ ## Spec format reference
346
+
347
+ Read from the installed package, NEVER guess the format:
348
+ - \`node_modules/openuispec/README.md\` — schema tables, file format
349
+ - \`node_modules/openuispec/spec/openuispec-v0.2.md\` — full specification
350
+ - \`node_modules/openuispec/schema/\` — JSON Schemas
351
+ - Online fallback: \`https://openuispec.rsteam.uz/llms-full.txt\`
352
+
353
+ ## Spec location
354
+ - Spec root: \`${specDir}/\` — read \`${specDir}/openuispec.yaml\` first.
355
+ - Default dirs: tokens/, screens/, flows/, contracts/, components/, platform/, locales/
356
+
357
+ ## If spec directories are empty
358
+ Read \`spec/openuispec-v0.2.md\` from the package, then create stubs:
359
+ screens → \`${specDir}/screens/\`, tokens → \`${specDir}/tokens/\`,
360
+ contracts → \`${specDir}/contracts/\`, locales → \`${specDir}/locales/\`
361
+
362
+ ## Rules
363
+ - NEVER write UI code without calling an OpenUISpec tool first.
364
+ - NEVER snapshot drift without user approval.
365
+ - NEVER modify generated UI without checking whether the spec must change first.
366
+ - ALWAYS read spec format from the installed package, not from memory.
367
+ - After generation, remind the user: \`openuispec drift --snapshot --target <t>\`
368
+ ${RULES_END_MARKER}
369
+ `;
370
+ }
371
+ // ── update-rules ────────────────────────────────────────────────────
372
+ export function updateRules() {
373
+ const cwd = process.cwd();
374
+ const version = getPackageVersion();
375
+ // Detect spec dir from existing openuispec.yaml
376
+ let specDir = "openuispec";
377
+ for (const candidate of ["openuispec", "spec", "."]) {
378
+ if (existsSync(join(cwd, candidate, "openuispec.yaml"))) {
379
+ specDir = candidate;
380
+ break;
381
+ }
382
+ }
383
+ // Detect targets from manifest
384
+ let targets = [...SUPPORTED_TARGETS];
385
+ try {
386
+ const manifest = readFileSync(join(cwd, specDir, "openuispec.yaml"), "utf-8");
387
+ const match = manifest.match(/targets:\s*\[([^\]]+)\]/);
388
+ if (match) {
389
+ const parsedTargets = match[1]
390
+ .split(",")
391
+ .map((t) => t.trim().replace(/['"]/g, ""))
392
+ .filter(isSupportedTarget);
393
+ if (parsedTargets.length > 0) {
394
+ targets = parsedTargets;
395
+ }
396
+ }
397
+ }
398
+ catch { }
399
+ const rules = aiRulesBlock(specDir, targets);
400
+ let updated = 0;
401
+ for (const file of ["CLAUDE.md", "AGENTS.md"]) {
402
+ const filePath = join(cwd, file);
403
+ if (!existsSync(filePath))
404
+ continue;
405
+ const content = readFileSync(filePath, "utf-8");
406
+ // Try marker-based replacement first
407
+ const startIdx = content.indexOf(RULES_START_MARKER);
408
+ const endIdx = content.indexOf(RULES_END_MARKER);
409
+ if (startIdx !== -1 && endIdx !== -1) {
410
+ const before = content.slice(0, startIdx);
411
+ const after = content.slice(endIdx + RULES_END_MARKER.length);
412
+ writeFileSync(filePath, before + rules.trimStart().trimEnd() + after);
413
+ console.log(` updated ${file} (v${version})`);
414
+ updated++;
415
+ continue;
416
+ }
417
+ // Fallback: find the OpenUISpec rules block by header pattern
418
+ // The block runs from the header to EOF (it's always the last content)
419
+ const headerIdx = content.indexOf("# OpenUISpec — AI Assistant Rules");
420
+ if (headerIdx !== -1) {
421
+ const before = content.slice(0, headerIdx);
422
+ const newContent = before + rules.trimStart().trimEnd() + "\n";
423
+ writeFileSync(filePath, newContent);
424
+ console.log(` updated ${file} (v${version}, migrated to markers)`);
425
+ updated++;
426
+ continue;
427
+ }
428
+ console.log(` skip ${file} (no OpenUISpec rules block found)`);
429
+ }
430
+ if (updated === 0) {
431
+ console.log("No CLAUDE.md or AGENTS.md with OpenUISpec rules found.\nRun `openuispec init` first.");
432
+ }
433
+ else {
434
+ console.log(`\nAI rules updated to v${version}`);
435
+ }
436
+ // Migrate old spec doc version references to current
437
+ migrateDocVersionRefs(cwd, specDir);
438
+ // Ensure MCP server is configured
439
+ configureMcp(cwd, true);
440
+ }
441
+ // ── spec doc version migration ──────────────────────────────────────
442
+ function getCurrentSpecDocVersion() {
443
+ const specDir = resolvePackagePath(import.meta.url, "spec");
444
+ try {
445
+ const files = readdirSync(specDir);
446
+ const match = files
447
+ .map((f) => f.match(/^openuispec-v(.+)\.md$/))
448
+ .filter(Boolean)
449
+ .sort()
450
+ .pop();
451
+ return match ? match[1] : null;
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ }
457
+ function migrateDocVersionRefs(cwd, specDir) {
458
+ const currentVersion = getCurrentSpecDocVersion();
459
+ if (!currentVersion)
460
+ return;
461
+ const currentRef = `openuispec-v${currentVersion}`;
462
+ const pattern = /openuispec-v(\d+\.\d+)/g;
463
+ const filesToCheck = [
464
+ join(cwd, specDir, "README.md"),
465
+ join(cwd, "CLAUDE.md"),
466
+ join(cwd, "AGENTS.md"),
467
+ ];
468
+ for (const filePath of filesToCheck) {
469
+ if (!existsSync(filePath))
470
+ continue;
471
+ const content = readFileSync(filePath, "utf-8");
472
+ const oldRefs = [...content.matchAll(pattern)]
473
+ .filter((m) => m[1] !== currentVersion);
474
+ if (oldRefs.length === 0)
475
+ continue;
476
+ const migrated = content.replace(pattern, (_match, ver) => ver === currentVersion ? _match : currentRef);
477
+ writeFileSync(filePath, migrated);
478
+ const relPath = relative(cwd, filePath);
479
+ const oldVersions = [...new Set(oldRefs.map((m) => m[1]))].join(", ");
480
+ console.log(` updated ${relPath} (migrated v${oldVersions} → v${currentVersion} doc references)`);
481
+ }
482
+ }
483
+ // ── shared MCP config ───────────────────────────────────────────────
484
+ const EXPECTED_MCP_CONFIG = {
485
+ command: "openuispec",
486
+ args: ["mcp"],
487
+ };
488
+ /**
489
+ * MCP config files by agent:
490
+ * .mcp.json — Claude Code (project scope)
491
+ * .vscode/mcp.json — VS Code / Copilot Chat
492
+ * .gemini/settings.json — Gemini CLI (if .gemini/ exists)
493
+ *
494
+ * All use the same { mcpServers: { openuispec: { command, args } } } shape.
495
+ */
496
+ const JSON_MCP_PATHS = [
497
+ ".mcp.json",
498
+ join(".vscode", "mcp.json"),
499
+ join(".gemini", "settings.json"),
500
+ ];
501
+ /** Codex uses TOML: .codex/config.toml */
502
+ const CODEX_CONFIG_PATH = join(".codex", "config.toml");
503
+ const CODEX_MCP_BLOCK = `\n[mcp_servers.openuispec]\ncommand = "openuispec"\nargs = ["mcp"]\n`;
504
+ function configureCodexMcp(cwd, quiet) {
505
+ const codexDir = join(cwd, ".codex");
506
+ const configPath = join(codexDir, "config.toml");
507
+ try {
508
+ let content = "";
509
+ try {
510
+ content = readFileSync(configPath, "utf-8");
511
+ }
512
+ catch {
513
+ // file doesn't exist — will create
514
+ }
515
+ if (content.includes("[mcp_servers.openuispec]")) {
516
+ if (!quiet)
517
+ console.log(` skip ${CODEX_CONFIG_PATH} (openuispec MCP already configured)`);
518
+ return false;
519
+ }
520
+ if (!existsSync(codexDir))
521
+ mkdirSync(codexDir);
522
+ writeFileSync(configPath, content + CODEX_MCP_BLOCK);
523
+ if (!quiet)
524
+ console.log(` ${content ? "update" : "create"} ${CODEX_CONFIG_PATH} (MCP server configured)`);
525
+ return true;
526
+ }
527
+ catch {
528
+ if (!quiet)
529
+ console.log(` skip ${CODEX_CONFIG_PATH} (could not configure MCP server)`);
530
+ return false;
531
+ }
532
+ }
533
+ function configureMcp(cwd, showRestart, quiet = false) {
534
+ let changed = false;
535
+ for (const relPath of JSON_MCP_PATHS) {
536
+ const configPath = join(cwd, relPath);
537
+ // Optional dirs: only write config if the parent directory already exists
538
+ const optionalParent = [".vscode", ".gemini"].find((d) => relPath.startsWith(d));
539
+ if (optionalParent && !existsSync(join(cwd, optionalParent)))
540
+ continue;
541
+ try {
542
+ let config = {};
543
+ try {
544
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
545
+ }
546
+ catch {
547
+ // file doesn't exist or isn't valid JSON — start fresh
548
+ }
549
+ if (!config.mcpServers)
550
+ config.mcpServers = {};
551
+ const existing = config.mcpServers.openuispec;
552
+ const needsUpdate = !existing ||
553
+ existing.command !== EXPECTED_MCP_CONFIG.command ||
554
+ JSON.stringify(existing.args) !== JSON.stringify(EXPECTED_MCP_CONFIG.args);
555
+ if (needsUpdate) {
556
+ config.mcpServers.openuispec = { ...EXPECTED_MCP_CONFIG };
557
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
558
+ if (!quiet)
559
+ console.log(` ${existing ? "update" : "create"} ${relPath} (MCP server configured)`);
560
+ changed = true;
561
+ }
562
+ else {
563
+ if (!quiet)
564
+ console.log(` skip ${relPath} (openuispec MCP already configured)`);
565
+ }
566
+ }
567
+ catch {
568
+ if (!quiet)
569
+ console.log(` skip ${relPath} (could not configure MCP server)`);
570
+ }
571
+ }
572
+ // Codex: .codex/config.toml (TOML format)
573
+ if (configureCodexMcp(cwd, quiet))
574
+ changed = true;
575
+ if (showRestart && changed)
576
+ console.log(`\n Restart your AI coding agent to activate the MCP server.`);
577
+ // Clean up stale .claude.json MCP config from older versions
578
+ const claudeJsonPath = join(cwd, ".claude.json");
579
+ try {
580
+ const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
581
+ if (claudeJson.mcpServers?.openuispec) {
582
+ delete claudeJson.mcpServers.openuispec;
583
+ if (Object.keys(claudeJson.mcpServers).length === 0)
584
+ delete claudeJson.mcpServers;
585
+ if (Object.keys(claudeJson).length === 0) {
586
+ unlinkSync(claudeJsonPath);
587
+ if (!quiet)
588
+ console.log(` remove .claude.json (migrated MCP config)`);
589
+ }
590
+ else {
591
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2) + "\n");
592
+ if (!quiet)
593
+ console.log(` update .claude.json (removed stale MCP config)`);
594
+ }
595
+ }
596
+ }
597
+ catch {
598
+ // .claude.json doesn't exist or not parseable — nothing to clean up
599
+ }
600
+ }
601
+ /**
602
+ * Extract the rules version from a CLAUDE.md / AGENTS.md file.
603
+ * Returns null if no version marker is found.
604
+ */
605
+ export function extractRulesVersion(filePath) {
606
+ if (!existsSync(filePath))
607
+ return null;
608
+ const content = readFileSync(filePath, "utf-8");
609
+ const match = content.match(/<!-- openuispec-rules-version:\s*([^\s]+)\s*-->/);
610
+ return match ? match[1] : null;
611
+ }
612
+ export { getPackageVersion };
613
+ function parseTargetsValue(raw) {
614
+ return raw
615
+ .split(",")
616
+ .map((value) => value.trim().toLowerCase())
617
+ .filter((value) => isSupportedTarget(value));
618
+ }
619
+ function requireFlagValue(argv, index, flag) {
620
+ const value = argv[index + 1];
621
+ if (!value || value.startsWith("--")) {
622
+ console.error(`Error: ${flag} requires a value.`);
623
+ process.exit(1);
624
+ }
625
+ return value;
626
+ }
627
+ function parseInitArgs(argv) {
628
+ const options = { defaults: argv.includes("--defaults"), quiet: argv.includes("--quiet") };
629
+ for (let index = 0; index < argv.length; index++) {
630
+ const arg = argv[index];
631
+ switch (arg) {
632
+ case "--defaults":
633
+ case "--quiet":
634
+ break;
635
+ case "--name":
636
+ options.name = requireFlagValue(argv, index, arg);
637
+ index++;
638
+ break;
639
+ case "--spec-dir":
640
+ options.specDir = requireFlagValue(argv, index, arg);
641
+ index++;
642
+ break;
643
+ case "--targets":
644
+ options.targets = parseTargetsValue(requireFlagValue(argv, index, arg));
645
+ index++;
646
+ break;
647
+ case "--backend":
648
+ options.backendPath = requireFlagValue(argv, index, arg);
649
+ index++;
650
+ break;
651
+ case "--with-api":
652
+ options.withApi = true;
653
+ break;
654
+ case "--no-api":
655
+ options.withApi = false;
656
+ break;
657
+ case "--configure-targets":
658
+ options.configureTargets = true;
659
+ break;
660
+ case "--no-configure-targets":
661
+ options.configureTargets = false;
662
+ break;
663
+ case "--with-shared":
664
+ options.withShared = true;
665
+ break;
666
+ case "--no-shared":
667
+ options.withShared = false;
668
+ break;
669
+ default:
670
+ if (arg.startsWith("--")) {
671
+ console.error(`Error: Unknown init option: ${arg}`);
672
+ process.exit(1);
673
+ }
674
+ }
675
+ }
676
+ return options;
677
+ }
678
+ function collectDefaults() {
679
+ const cwd = process.cwd();
680
+ const defaultName = cwd.split("/").pop() || "MyApp";
681
+ return {
682
+ name: defaultName,
683
+ specDir: "openuispec",
684
+ targets: [...SUPPORTED_TARGETS],
685
+ withApi: true,
686
+ backendPath: "../backend/",
687
+ configureTargets: true,
688
+ sharedLayers: [],
689
+ structures: [],
690
+ };
691
+ }
692
+ const SHARED_LAYER_DEFAULTS = {
693
+ kmp: {
694
+ language: "kotlin",
695
+ root: "../shared",
696
+ tracks: [],
697
+ scope: "Business logic, data models, repositories, API clients, view models/stores. No UI rendering.",
698
+ paths: { domain: "commonMain/domain/", features: "commonMain/features/" },
699
+ structureScope: {
700
+ ios: "Pure SwiftUI views and navigation. All business logic comes from the shared layer.",
701
+ android: "Pure Compose UI and navigation. All business logic comes from the shared layer.",
702
+ },
703
+ },
704
+ };
705
+ function defaultSharedConfig(targets) {
706
+ const mobilePlatforms = targets.filter((t) => t === "ios" || t === "android");
707
+ if (mobilePlatforms.length < 2) {
708
+ return { sharedLayers: [], structures: [] };
709
+ }
710
+ const preset = SHARED_LAYER_DEFAULTS.kmp;
711
+ const sharedLayers = [{
712
+ name: "mobile_common",
713
+ platforms: mobilePlatforms,
714
+ language: preset.language,
715
+ root: preset.root,
716
+ tracks: preset.tracks,
717
+ scope: preset.scope,
718
+ paths: preset.paths,
719
+ }];
720
+ const structures = mobilePlatforms.map((t) => ({
721
+ target: t,
722
+ root: preset.root,
723
+ scope: preset.structureScope[t] ?? `Pure ${t} UI. Business logic comes from the shared layer.`,
724
+ paths: { ui: `${t}App/ui/` },
725
+ }));
726
+ return { sharedLayers, structures };
727
+ }
728
+ async function collectSharedLayerAnswers(rl, targets) {
729
+ const withShared = await askYesNo(rl, "\nShare code between platforms (e.g. KMP commonMain)?", false);
730
+ if (!withShared)
731
+ return { sharedLayers: [], structures: [] };
732
+ const defaults = defaultSharedConfig(targets);
733
+ const defaultLayer = defaults.sharedLayers[0];
734
+ if (!defaultLayer)
735
+ return { sharedLayers: [], structures: [] };
736
+ console.log("\n Shared layer defaults (KMP):");
737
+ console.log(` platforms: ${defaultLayer.platforms.join(", ")}`);
738
+ console.log(` language: ${defaultLayer.language}`);
739
+ console.log(` root: ${defaultLayer.root}`);
740
+ console.log(` scope: ${defaultLayer.scope}`);
741
+ const useDefaults = await askYesNo(rl, " Use these defaults?", true);
742
+ if (useDefaults)
743
+ return defaults;
744
+ const layerName = await ask(rl, " Shared layer name", defaultLayer.name);
745
+ const platformsRaw = await askList(rl, " Platforms", targets, defaultLayer.platforms);
746
+ const language = await ask(rl, " Language", defaultLayer.language);
747
+ const root = await ask(rl, " Root path (relative to openuispec.yaml)", defaultLayer.root);
748
+ const scope = await ask(rl, " Scope (what code belongs here)", defaultLayer.scope);
749
+ const wantTracks = await askYesNo(rl, " Enable hash-based drift tracking for this layer?", false);
750
+ const tracksRaw = wantTracks
751
+ ? await askList(rl, " Tracked spec categories", ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"], ["manifest", "contracts", "flows"])
752
+ : [];
753
+ const sharedLayers = [{
754
+ name: layerName,
755
+ platforms: platformsRaw,
756
+ language,
757
+ root,
758
+ tracks: tracksRaw,
759
+ scope,
760
+ paths: defaultLayer.paths,
761
+ }];
762
+ const structures = [];
763
+ for (const t of platformsRaw) {
764
+ const defaultStructure = defaults.structures.find((s) => s.target === t);
765
+ const structRoot = await ask(rl, ` ${t} structure root`, defaultStructure?.root ?? root);
766
+ const structScope = await ask(rl, ` ${t} scope (what code belongs in the ${t} target)`, defaultStructure?.scope ?? `Pure ${t} UI rendering.`);
767
+ structures.push({
768
+ target: t,
769
+ root: structRoot,
770
+ scope: structScope,
771
+ paths: defaultStructure?.paths ?? { ui: `${t}App/ui/` },
772
+ });
773
+ }
774
+ return { sharedLayers, structures };
775
+ }
776
+ async function collectInteractiveAnswers(rl) {
777
+ const defaults = collectDefaults();
778
+ const name = await ask(rl, "Project name", defaults.name);
779
+ const specDir = await ask(rl, "Spec directory", defaults.specDir);
780
+ const targets = await askList(rl, "\nWhich platforms?", [...SUPPORTED_TARGETS], defaults.targets);
781
+ if (targets.length === 0) {
782
+ console.error("At least one target is required.");
783
+ process.exit(1);
784
+ }
785
+ const withApi = await askYesNo(rl, "Will this spec declare API endpoints?", defaults.withApi);
786
+ const backendPath = withApi
787
+ ? await ask(rl, "Backend folder path relative to openuispec.yaml", defaults.backendPath ?? "../backend/")
788
+ : null;
789
+ const configureTargets = await askYesNo(rl, "Configure target stacks now?", defaults.configureTargets);
790
+ const { sharedLayers, structures } = await collectSharedLayerAnswers(rl, targets);
791
+ return {
792
+ name,
793
+ specDir,
794
+ targets,
795
+ withApi,
796
+ backendPath,
797
+ configureTargets,
798
+ sharedLayers,
799
+ structures,
800
+ };
801
+ }
802
+ function collectNonInteractiveAnswers(argv) {
803
+ const parsed = parseInitArgs(argv);
804
+ const defaults = collectDefaults();
805
+ if (!parsed.defaults && argv.filter((a) => a !== "--quiet").length === 0) {
806
+ console.error("Error: `openuispec init` needs a TTY for prompts.\n" +
807
+ "Run with `--list-options` to get prompt definitions as JSON, or pass flags such as `--name`, `--targets`, `--with-api`, `--backend`, and `--configure-targets`.");
808
+ process.exit(1);
809
+ }
810
+ const targets = parsed.targets && parsed.targets.length > 0 ? parsed.targets : defaults.targets;
811
+ if (targets.length === 0) {
812
+ console.error("Error: --targets must include at least one of ios, android, web.");
813
+ process.exit(1);
814
+ }
815
+ const withApi = parsed.withApi ?? defaults.withApi;
816
+ const backendPath = withApi ? parsed.backendPath ?? defaults.backendPath : null;
817
+ const withShared = parsed.withShared ?? false;
818
+ const { sharedLayers, structures } = withShared ? defaultSharedConfig(targets) : { sharedLayers: [], structures: [] };
819
+ return {
820
+ name: parsed.name ?? defaults.name,
821
+ specDir: parsed.specDir ?? defaults.specDir,
822
+ targets,
823
+ withApi,
824
+ backendPath,
825
+ configureTargets: parsed.configureTargets ?? defaults.configureTargets,
826
+ sharedLayers,
827
+ structures,
828
+ };
829
+ }
830
+ // ── main ─────────────────────────────────────────────────────────────
831
+ export async function init(argv = []) {
832
+ if (argv.includes("--list-options")) {
833
+ console.log(JSON.stringify(listInitOptions(), null, 2));
834
+ return;
835
+ }
836
+ const quiet = argv.includes("--quiet");
837
+ const interactive = stdin.isTTY && stdout.isTTY && !argv.includes("--defaults");
838
+ const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
839
+ if (!quiet)
840
+ console.log("\nOpenUISpec — Project Setup\n");
841
+ try {
842
+ const cwd = process.cwd();
843
+ const answers = rl ? await collectInteractiveAnswers(rl) : collectNonInteractiveAnswers(argv);
844
+ rl?.close();
845
+ // ── create folders ─────────────────────────────────────────────
846
+ if (!quiet)
847
+ console.log("\nScaffolding...\n");
848
+ const root = join(cwd, answers.specDir);
849
+ const dirs = [
850
+ "tokens",
851
+ "contracts",
852
+ "components",
853
+ "screens",
854
+ "flows",
855
+ "platform",
856
+ "locales",
857
+ ];
858
+ ensureDir(root);
859
+ for (const d of dirs) {
860
+ ensureDir(join(root, d));
861
+ }
862
+ // ── manifest ───────────────────────────────────────────────────
863
+ writeIfMissing(join(root, "openuispec.yaml"), manifestTemplate(answers.name, answers.targets, {
864
+ withApi: answers.withApi,
865
+ backendPath: answers.backendPath,
866
+ sharedLayers: answers.sharedLayers,
867
+ structures: answers.structures,
868
+ }), quiet);
869
+ // ── spec README ──────────────────────────────────────────────
870
+ writeIfMissing(join(root, "README.md"), specReadmeTemplate(answers.name, answers.targets), quiet);
871
+ // ── .gitkeep for empty dirs ────────────────────────────────────
872
+ for (const d of dirs) {
873
+ const dir = join(root, d);
874
+ const entries = existsSync(dir)
875
+ ? readdirSync(dir).filter((f) => f !== ".gitkeep")
876
+ : [];
877
+ if (entries.length === 0) {
878
+ const gk = join(dir, ".gitkeep");
879
+ if (!existsSync(gk)) {
880
+ writeFileSync(gk, "");
881
+ if (!quiet)
882
+ console.log(` create ${relative(cwd, gk)}`);
883
+ }
884
+ }
885
+ }
886
+ // ── AI assistant rules ─────────────────────────────────────────
887
+ const rules = aiRulesBlock(answers.specDir, answers.targets);
888
+ for (const file of ["CLAUDE.md", "AGENTS.md"]) {
889
+ const filePath = join(cwd, file);
890
+ if (existsSync(filePath)) {
891
+ const existing = readFileSync(filePath, "utf-8");
892
+ if (existing.includes("OpenUISpec")) {
893
+ if (!quiet)
894
+ console.log(` skip ${file} (already has OpenUISpec rules)`);
895
+ continue;
896
+ }
897
+ appendFileSync(filePath, "\n" + rules);
898
+ if (!quiet)
899
+ console.log(` update ${file} (appended rules)`);
900
+ }
901
+ else {
902
+ writeFileSync(filePath, rules.trimStart());
903
+ if (!quiet)
904
+ console.log(` create ${file}`);
905
+ }
906
+ }
907
+ // ── MCP server configuration ────────────────────────────────────
908
+ configureMcp(cwd, false, quiet);
909
+ if (answers.configureTargets) {
910
+ if (!quiet)
911
+ console.log("\nConfiguring target stacks...\n");
912
+ const { runConfigureTarget } = await import("./configure-target.js");
913
+ for (const target of answers.targets) {
914
+ await runConfigureTarget([target, ...(interactive ? [] : ["--defaults"]), ...(quiet ? ["--silent"] : [])]);
915
+ }
916
+ }
917
+ // ── done ───────────────────────────────────────────────────────
918
+ if (quiet) {
919
+ console.log(`./${answers.specDir}/`);
920
+ }
921
+ else {
922
+ console.log(`
923
+ Done! Your spec project is ready at ./${answers.specDir}/
924
+
925
+ Getting started (new project):
926
+ 1. Edit ${answers.specDir}/openuispec.yaml — define your data model and API
927
+ 2. Create tokens in ${answers.specDir}/tokens/ (colors, typography, spacing, etc.)
928
+ 3. Create contract extensions in ${answers.specDir}/contracts/ (visual variants for the 7 built-in contracts)
929
+ 4. Create screens in ${answers.specDir}/screens/ (one YAML per screen)
930
+ 5. Create flows in ${answers.specDir}/flows/ (multi-step navigation)
931
+ 6. Create locale files in ${answers.specDir}/locales/ (one JSON per supported locale)
932
+ 7. Run \`openuispec validate\` and \`openuispec validate semantic\` to check everything
933
+ 8. Ask AI to generate native code from the spec
934
+ 9. Run \`openuispec drift --snapshot --target ${answers.targets[0]}\` to baseline the first accepted target state after that target output directory exists
935
+
936
+ Getting started (existing project):
937
+ 1. Ask AI to read your existing UI code and generate spec files:
938
+ "Read src/screens/HomeScreen.swift and create ${answers.specDir}/screens/home.yaml as status: stub"
939
+ 2. Spec screens incrementally: stub → draft → ready
940
+ 3. Only ready/draft screens are tracked by drift detection
941
+ 4. Run \`openuispec validate\` to check specs against the schema
942
+ 5. Use \`openuispec prepare --target ${answers.targets[0]}\` before first-time generation, then use \`openuispec drift --target ${answers.targets[0]} --explain\` and \`openuispec prepare --target ${answers.targets[0]}\` before asking AI to update a target
943
+
944
+ Commands:
945
+ openuispec validate Validate spec files
946
+ openuispec validate semantic Check semantic cross-references
947
+ openuispec configure-target ios [--defaults] Configure target stack; --defaults stays unconfirmed
948
+ openuispec status Show cross-target baseline/drift status
949
+ openuispec drift --target ios --explain Explain semantic spec changes
950
+ openuispec prepare --target ios Build the target work bundle
951
+ openuispec drift --snapshot --target ios Save current state + git baseline after target output exists
952
+
953
+ AI rules have been added to CLAUDE.md and AGENTS.md.
954
+ MCP server configured (AI assistants will use openuispec tools automatically).
955
+
956
+ Docs: https://openuispec.rsteam.uz
957
+ `);
958
+ }
959
+ }
960
+ catch (err) {
961
+ rl?.close();
962
+ throw err;
963
+ }
964
+ }