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.
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 +1047 -0
  6. package/dist/drift/index.js +903 -0
  7. package/dist/mcp-server/index.js +886 -0
  8. package/dist/mcp-server/preview-render.js +1761 -0
  9. package/dist/mcp-server/preview.js +233 -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 +180 -0
  13. package/dist/mcp-server/screenshot.js +459 -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 +12 -13
  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
package/cli/init.ts DELETED
@@ -1,1253 +0,0 @@
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
-
8
- import { createInterface } from "node:readline/promises";
9
- import { stdin, stdout } from "node:process";
10
- import {
11
- mkdirSync,
12
- writeFileSync,
13
- readFileSync,
14
- readdirSync,
15
- existsSync,
16
- appendFileSync,
17
- unlinkSync,
18
- } from "node:fs";
19
- import { join, relative, dirname, resolve } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
- import { SUPPORTED_TARGETS, isSupportedTarget, type SupportedTarget } from "../drift/index.js";
22
-
23
- // ── list-options ────────────────────────────────────────────────────
24
-
25
- export type InitOptionsResponse = {
26
- command: "init";
27
- note: string;
28
- questions: Array<{
29
- key: string;
30
- prompt: string;
31
- type: "text" | "list" | "yes_no";
32
- default: string | string[] | boolean;
33
- options?: string[];
34
- }>;
35
- configure_targets_note: string;
36
- shared_layer_note?: string;
37
- };
38
-
39
- export function listInitOptions(): InitOptionsResponse {
40
- const defaults = collectDefaults();
41
- return {
42
- command: "init",
43
- note: "After init, run `openuispec configure-target <target> --list-options` for each target to get stack choices.",
44
- questions: [
45
- {
46
- key: "name",
47
- prompt: "Project name",
48
- type: "text",
49
- default: defaults.name,
50
- },
51
- {
52
- key: "spec_dir",
53
- prompt: "Spec directory",
54
- type: "text",
55
- default: defaults.specDir,
56
- },
57
- {
58
- key: "targets",
59
- prompt: "Which platforms?",
60
- type: "list",
61
- default: defaults.targets,
62
- options: [...SUPPORTED_TARGETS],
63
- },
64
- {
65
- key: "with_api",
66
- prompt: "Will this spec declare API endpoints?",
67
- type: "yes_no",
68
- default: defaults.withApi,
69
- },
70
- {
71
- key: "backend_path",
72
- prompt: "Backend folder path relative to openuispec.yaml",
73
- type: "text",
74
- default: defaults.backendPath ?? "../backend/",
75
- },
76
- {
77
- key: "configure_targets",
78
- prompt: "Configure target stacks now?",
79
- type: "yes_no",
80
- default: defaults.configureTargets,
81
- },
82
- {
83
- key: "with_shared",
84
- prompt: "Does the project share code between platforms (e.g. KMP commonMain)?",
85
- type: "yes_no",
86
- default: false,
87
- },
88
- ],
89
- configure_targets_note:
90
- "If configure_targets is true, use `openuispec configure-target <target> --list-options` for each target after init to present stack choices to the user.",
91
- shared_layer_note:
92
- "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.",
93
- };
94
- }
95
-
96
- // ── prompts ──────────────────────────────────────────────────────────
97
-
98
- export async function ask(
99
- rl: ReturnType<typeof createInterface>,
100
- question: string,
101
- fallback?: string
102
- ): Promise<string> {
103
- const suffix = fallback ? ` (${fallback})` : "";
104
- const answer = (await rl.question(`${question}${suffix}: `)).trim();
105
- return answer || fallback || "";
106
- }
107
-
108
- async function askList(
109
- rl: ReturnType<typeof createInterface>,
110
- question: string,
111
- options: string[],
112
- defaults: string[]
113
- ): Promise<string[]> {
114
- const defaultStr = defaults.join(", ");
115
- const raw = (
116
- await rl.question(`${question} [${options.join(", ")}] (${defaultStr}): `)
117
- ).trim();
118
- if (!raw) return defaults;
119
- return raw
120
- .split(",")
121
- .map((s) => s.trim().toLowerCase())
122
- .filter((s) => options.includes(s));
123
- }
124
-
125
- export async function askChoice(
126
- rl: ReturnType<typeof createInterface>,
127
- question: string,
128
- options: string[],
129
- fallback: string
130
- ): Promise<string> {
131
- const answer = (await rl.question(`${question} [${options.join(", ")}] (${fallback}): `))
132
- .trim()
133
- .toLowerCase();
134
- if (!answer) return fallback;
135
- return options.includes(answer) ? answer : fallback;
136
- }
137
-
138
- async function askYesNo(
139
- rl: ReturnType<typeof createInterface>,
140
- question: string,
141
- fallback: boolean
142
- ): Promise<boolean> {
143
- const answer = await askChoice(rl, question, ["yes", "no"], fallback ? "yes" : "no");
144
- return answer === "yes";
145
- }
146
-
147
- // ── scaffold ─────────────────────────────────────────────────────────
148
-
149
- function ensureDir(path: string): void {
150
- mkdirSync(path, { recursive: true });
151
- }
152
-
153
- function writeIfMissing(path: string, content: string, quiet = false): boolean {
154
- if (existsSync(path)) {
155
- if (!quiet) console.log(` skip ${relative(process.cwd(), path)} (exists)`);
156
- return false;
157
- }
158
- writeFileSync(path, content);
159
- if (!quiet) console.log(` create ${relative(process.cwd(), path)}`);
160
- return true;
161
- }
162
-
163
- // ── version ─────────────────────────────────────────────────────────
164
-
165
- const RULES_START_MARKER = "<!-- openuispec-rules-start -->";
166
- const RULES_END_MARKER = "<!-- openuispec-rules-end -->";
167
-
168
- function getPackageVersion(): string {
169
- const pkgPath = join(
170
- dirname(fileURLToPath(import.meta.url)),
171
- "..",
172
- "package.json"
173
- );
174
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
175
- return pkg.version;
176
- }
177
-
178
- // ── templates ────────────────────────────────────────────────────────
179
-
180
- function manifestTemplate(
181
- name: string,
182
- targets: string[],
183
- options: {
184
- withApi: boolean;
185
- backendPath: string | null;
186
- sharedLayers: SharedLayerAnswers[];
187
- structures: StructureAnswers[];
188
- }
189
- ): string {
190
- const targetList = targets.join(", ");
191
- const outputLines = targets
192
- .map((t) => {
193
- if (t === "ios")
194
- return ` ios: { language: swift, framework: swiftui, min_version: "17.0" }`;
195
- if (t === "android")
196
- return ` android: { language: kotlin, framework: compose, min_sdk: 26 }`;
197
- if (t === "web")
198
- return ` web: { language: typescript, framework: react, bundler: vite }`;
199
- return ` ${t}: {}`;
200
- })
201
- .join("\n");
202
-
203
- function yamlPathsBlock(paths: Record<string, string>): string {
204
- const entries = Object.entries(paths);
205
- return entries.length > 0
206
- ? `\n paths:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
207
- : "";
208
- }
209
-
210
- let sharedBlock = "";
211
- if (options.sharedLayers.length > 0) {
212
- const layers = options.sharedLayers.map((layer) => {
213
- const tracksLine = layer.tracks.length > 0
214
- ? `\n tracks: [${layer.tracks.join(", ")}]`
215
- : "";
216
- return ` ${layer.name}:
217
- platforms: [${layer.platforms.join(", ")}]
218
- language: ${layer.language}
219
- root: "${layer.root}"
220
- scope: "${layer.scope}"${tracksLine}${yamlPathsBlock(layer.paths)}`;
221
- }).join("\n");
222
- sharedBlock = ` shared:\n${layers}\n`;
223
- }
224
-
225
- let structureBlock = "";
226
- if (options.structures.length > 0) {
227
- const entries = options.structures.map((s) => {
228
- const scopeLine = s.scope ? `\n scope: "${s.scope}"` : "";
229
- return ` ${s.target}:
230
- root: "${s.root}"${scopeLine}${yamlPathsBlock(s.paths)}`;
231
- }).join("\n");
232
- structureBlock = ` structure:\n${entries}\n`;
233
- }
234
-
235
- return `# ${name} — OpenUISpec v0.2
236
- spec_version: "0.2"
237
-
238
- project:
239
- name: "${name}"
240
- description: ""
241
-
242
- includes:
243
- tokens: "./tokens/"
244
- contracts: "./contracts/"
245
- components: "./components/"
246
- screens: "./screens/"
247
- flows: "./flows/"
248
- platform: "./platform/"
249
- locales: "./locales/"
250
-
251
- i18n:
252
- default_locale: "en"
253
- supported_locales: [en]
254
- fallback_strategy: "default"
255
-
256
- generation:
257
- targets: [${targetList}]
258
- # extra_rules: # Optional: project-wide AI authoring conventions
259
- # - "Generation hint strings may start with [common], [ios], [android], or [web] to indicate scope."
260
- # output_dir: # Optional: map targets to code directories
261
- # ios: "../ios-app/" # relative to this file
262
- # android: "../android-app/"
263
- # web: "../web-ui/"
264
- ${options.withApi ? ` code_roots:
265
- backend: "${options.backendPath}" # Required when api.endpoints are declared
266
- ` : ""} output_format:
267
- ${outputLines}
268
- ${sharedBlock}${structureBlock}
269
- data_model: {}
270
-
271
- api:
272
- base_url: "/api/v1"
273
- auth: "bearer_token"
274
- endpoints: {}
275
- `;
276
- }
277
-
278
- function specReadmeTemplate(name: string, targets: string[]): string {
279
- const targetList = targets.join(", ");
280
- return `# ${name} — OpenUISpec
281
-
282
- This directory contains the **OpenUISpec** semantic UI specification for **${name}**.
283
-
284
- **Start here:** read \`openuispec.yaml\` — it defines the project structure, data model, API endpoints, and generation targets (**${targetList}**).
285
-
286
- ## Directory structure
287
-
288
- | Directory | Contents |
289
- |-----------|----------|
290
- | \`tokens/\` | Design tokens — colors, typography, spacing, elevation, motion, icons, themes |
291
- | \`screens/\` | Screen definitions — one YAML file per screen |
292
- | \`flows/\` | Navigation flows — multi-step user journeys |
293
- | \`contracts/\` | Component contracts — standard extensions and custom (\`x_\` prefixed) |
294
- | \`components/\` | Reusable component compositions — contract slot compositions with states and variants |
295
- | \`platform/\` | Platform overrides — per-target (iOS, Android, Web) behaviors |
296
- | \`locales/\` | Localization — i18n strings (JSON, ICU MessageFormat) |
297
-
298
- ## IMPORTANT — Read the specification before working with spec files
299
-
300
- The spec format, file schemas, and generation rules are defined in the installed \`openuispec\` package.
301
- You MUST read these reference files before creating, editing, or generating from any spec file.
302
- Do NOT guess the file format — skipping this step will produce invalid YAML that fails validation.
303
-
304
- **Find the package in this order:**
305
- 1. \`node_modules/openuispec/\` (project dependency)
306
- 2. Run \`npm root -g\` → \`<prefix>/openuispec/\` (global install)
307
- 3. Online: \`https://openuispec.rsteam.uz/llms-full.txt\` (if not installed)
308
-
309
- **Reference files inside the package (read in this order):**
310
- 1. \`README.md\` — schema tables, file format reference, root wrapper keys
311
- 2. \`spec/openuispec-v0.2.md\` — full specification (contracts, layout, expressions, etc.)
312
- 3. \`examples/taskflow/openuispec/\` — complete working example with all file types
313
- 4. \`schema/\` — JSON Schemas for validation
314
-
315
- ## MCP Tools (recommended for AI assistants)
316
-
317
- When the openuispec MCP server is configured, AI assistants should use these tools instead of CLI commands:
318
-
319
- | Tool | When to use |
320
- |------|-------------|
321
- | \`openuispec_spec_types\` | Discover available spec types and their descriptions. |
322
- | \`openuispec_spec_schema\` | Get the full JSON schema for a specific spec type — exact structure, required fields, allowed values. |
323
- | \`openuispec_prepare\` | **Before any UI code generation.** Returns spec context, platform config, and constraints. |
324
- | \`openuispec_read_specs\` | Load spec file contents — the authoritative source for tokens, screens, contracts. |
325
- | \`openuispec_check\` | After editing spec files. Validates schema + semantics + readiness. Optional \`screens\`/\`contracts\` params scope the audit. |
326
- | \`openuispec_validate\` | Schema-only validation, optionally by group. |
327
- | \`openuispec_drift\` | Detect spec changes since last snapshot. |
328
- | \`openuispec_status\` | To understand cross-target state (baselines, drift, next steps). |
329
- | \`openuispec_get_screen\` | Get a single screen spec by name — faster than \`read_specs\` for targeted edits. |
330
- | \`openuispec_get_contract\` | Get a single contract spec, optionally filtered to one variant. |
331
- | \`openuispec_get_tokens\` | Get tokens for a specific category (color, typography, spacing, etc.). |
332
- | \`openuispec_get_locale\` | Get a single locale file, optionally filtered to specific keys. |
333
- | \`openuispec_screenshot\` | Screenshot the web app at a route via headless browser (requires \`puppeteer\`). |
334
- | \`openuispec_screenshot_android\` | Screenshot Android app on emulator — works with any project via \`project_dir\`. |
335
- | \`openuispec_screenshot_ios\` | Screenshot iOS app on Simulator via XCUITest — works with any project via \`project_dir\`. |
336
-
337
- ## CLI commands
338
-
339
- \`\`\`bash
340
- # Workflow
341
- openuispec validate # Validate spec files against schemas
342
- openuispec validate semantic # Run semantic cross-reference linting
343
- openuispec configure-target ${targets[0]} [--defaults] # Configure target stack; --defaults stays unconfirmed
344
- openuispec status # Show cross-target baseline/drift status
345
- openuispec drift --target ${targets[0]} --explain # Explain semantic spec drift
346
- openuispec prepare --target ${targets[0]} # Build the target work bundle
347
- openuispec drift --snapshot --target ${targets[0]} # Snapshot current state + git baseline after target output exists
348
-
349
- # Spec access
350
- openuispec read-specs [paths...] # Read spec file contents as JSON
351
- openuispec get-screen <name> # Get a single screen spec
352
- openuispec get-contract <name> [--variant v] # Get a contract spec
353
- openuispec get-tokens <category> # Get tokens for a category
354
- openuispec get-locale <locale> [--keys k1,k2] # Get a locale file
355
- openuispec spec-types # List available spec types
356
- openuispec spec-schema <type> # Get JSON schema for a spec type
357
-
358
- # Screenshots
359
- openuispec screenshot --route /home --theme dark --output-dir screenshots
360
- openuispec screenshot-android --screen home --project-dir /path/to/android
361
- openuispec screenshot-ios --screen home --project-dir /path/to/ios --scheme MyApp
362
- \`\`\`
363
-
364
- The target work bundle has two modes:
365
- - \`bootstrap\` when no snapshot exists yet, for first-time generation
366
- - \`update\` after a snapshot exists, for drift-based target updates
367
-
368
- 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\`.
369
-
370
- ## Learn more
371
-
372
- Docs: https://openuispec.rsteam.uz
373
- `;
374
- }
375
-
376
- function aiRulesBlock(specDir: string, targets: string[]): string {
377
- const targetList = targets.map((t) => `"${t}"`).join(", ");
378
- const version = getPackageVersion();
379
- return `
380
- ${RULES_START_MARKER}
381
- <!-- openuispec-rules-version: ${version} -->
382
- # OpenUISpec — AI Assistant Rules
383
- # ================================
384
- # This project uses OpenUISpec to define UI as a semantic spec.
385
- # Spec files are the single source of truth for all UI across platforms.
386
- # Targets: ${targetList}
387
-
388
- ## MANDATORY — UI work requires OpenUISpec tools
389
-
390
- When the user's request involves UI — screens, navigation, layout, tokens, flows, localization,
391
- or any visual/structural change — you MUST use the OpenUISpec tools before writing any code.
392
-
393
- ### MCP Tools (use these when available)
394
-
395
- Call these MCP tools directly. They return structured JSON with everything you need.
396
-
397
- **Pre-generation:**
398
- 1. Call \`openuispec_prepare\` with the target platform — returns spec context, platform config, constraints.
399
- Use \`include_specs: true\` to embed all spec contents in one call (saves a separate read_specs).
400
- 2. Call \`openuispec_read_specs\` to load spec file contents if not using include_specs.
401
- Without paths: returns file listing. With paths: returns contents. Use these as the AUTHORITATIVE source.
402
- 3. If spec changes are needed, update spec files FIRST, then call \`openuispec_check\`.
403
- 4. Generate or update the platform UI code based on the spec contents.
404
-
405
- **Post-generation (EVERY TIME after writing UI code):**
406
- 5. Call \`openuispec_check\` to validate spec files (schema + semantics) and confirm prepare readiness.
407
- Note: this validates the SPEC, not the generated code.
408
- 6. Call \`openuispec_check\` with \`audit: true\` to get a spec-derived checklist, then manually review
409
- the generated code against it. For each screen, verify:
410
- - Every field/action in the spec has a corresponding UI element
411
- - Token values (colors, spacing, radii) match exactly — no approximations
412
- - Contract \`must_handle\` states are all implemented (loading, error, empty, etc.)
413
- - Adaptive breakpoints match the spec's \`size_classes\`
414
- - Locale keys match \`$t:\` references
415
- - Navigation targets match flow definitions
416
- 7. Report any real gaps found and fix them before finishing.
417
-
418
- **Iterating before baseline:**
419
- Generated code rarely needs just one pass. Read the spec, audit the generated code against it,
420
- take screenshots to verify visuals, then fix gaps and repeat.
421
- Multiple generate → review → fix cycles are expected before the user accepts the result.
422
-
423
- **Baseline reminder:**
424
- After generation, remind the user to review the output and run the baseline when satisfied:
425
- > When you're happy with the generated output, run: \`openuispec drift --snapshot --target <t>\`
426
- > This records the spec state so future changes are tracked as incremental drift.
427
- Do not baseline on your own initiative — only run the snapshot when the user asks.
428
-
429
- **Creating new spec files:**
430
- - Call \`openuispec_spec_types\` to discover available spec types.
431
- - Call \`openuispec_spec_schema\` with the specific type to get the full JSON schema.
432
- - Write the spec file following the schema exactly.
433
-
434
- **Focused getters (prefer these for incremental edits over \`read_specs\`):**
435
- - \`openuispec_get_screen(name)\` — single screen spec
436
- - \`openuispec_get_contract(name, variant?)\` — single contract, optionally one variant
437
- - \`openuispec_get_component(name, variant?)\` — single component, optionally one variant
438
- - \`openuispec_get_tokens(category)\` — single token category (color, typography, spacing, etc.)
439
- - \`openuispec_get_locale(locale, keys?)\` — single locale file, optionally filtered keys
440
- - \`openuispec_check(target, audit?, screens?, contracts?)\` — validation + optional scoped audit checklist
441
-
442
- Use \`read_specs\` for full-project generation; use focused getters when editing one screen or contract.
443
-
444
- **Other tools:**
445
- - \`openuispec_status\` — cross-target summary, good starting point
446
- - \`openuispec_drift\` with \`explain: true\` — property-level spec changes
447
- - \`openuispec_validate\` — schema-only validation by group
448
-
449
- ### CLI fallback (when MCP is not available)
450
-
451
- If MCP tools are not available, use these CLI commands with \`--json\` flag:
452
-
453
- **Status & discovery:**
454
- - \`openuispec status --json\` — cross-target status
455
- - \`openuispec spec-types\` — list available spec types
456
- - \`openuispec spec-schema <type>\` — get JSON schema for a spec type
457
-
458
- **Spec access:**
459
- - \`openuispec read-specs [paths...]\` — read spec file contents
460
- - \`openuispec get-screen <name>\` — get a single screen spec
461
- - \`openuispec get-contract <name> [--variant v]\` — get a contract spec
462
- - \`openuispec get-component <name> [--variant v]\` — get a component spec
463
- - \`openuispec get-tokens <category>\` — get tokens for a category
464
- - \`openuispec get-locale <locale> [--keys k1,k2]\` — get a locale file
465
-
466
- **Validation & generation workflow:**
467
- - \`openuispec validate [group...] --json\` — validate spec files against JSON Schemas
468
- - \`openuispec check --target <t> --json\` — validate spec files + check target generation readiness
469
- - \`openuispec prepare --target <t> --json\` — build AI-ready work bundle
470
- - \`openuispec drift --target <t> --explain --json\` — semantic drift
471
-
472
- **Visual verification:**
473
- - \`openuispec screenshot --route /path\` — screenshot the web app
474
- - \`openuispec screenshot --route /path --init-script "..."\` — inject auth/role before rendering (web only; app must implement \`__ous_init\` bootstrapper)
475
- - \`openuispec screenshot-android [--project-dir path]\` — screenshot Android app
476
- - \`openuispec screenshot-ios [--project-dir path]\` — screenshot iOS app
477
-
478
- ### Other CLI commands
479
- - \`openuispec init\` — scaffold a new spec project
480
- - \`openuispec configure-target <t>\` — configure target platform stack
481
- - \`openuispec update-rules\` — update AI rules to match installed package version
482
- - \`openuispec drift --snapshot --target <t>\` — snapshot current state (user-initiated, after reviewing generated output)
483
-
484
- ## Spec format reference
485
-
486
- The spec format, schemas, and generation rules are in the installed \`openuispec\` package.
487
- You MUST read the reference files before creating or editing spec files — do NOT guess the format.
488
-
489
- **Find the package:** \`node_modules/openuispec/\` or run \`npm root -g\` → \`<prefix>/openuispec/\`.
490
- **Online fallback:** \`https://openuispec.rsteam.uz/llms-full.txt\`
491
-
492
- **Reference files (read in order):**
493
- 1. \`README.md\` — schema tables, file format, root wrapper keys
494
- 2. \`spec/openuispec-v0.2.md\` — full specification
495
- 3. \`examples/taskflow/openuispec/\` — complete working example
496
- 4. \`schema/\` — JSON Schemas for every file type
497
-
498
- ## Spec location
499
- - Spec root: \`${specDir}/\` — read \`${specDir}/openuispec.yaml\` first for actual paths.
500
- - Default dirs: tokens/, screens/, flows/, contracts/, components/, platform/, locales/
501
-
502
- ## When to start from spec vs platform code
503
-
504
- **Spec-first** (use \`openuispec_prepare\` or \`openuispec prepare\`):
505
- - Screen structure, navigation, fields, actions, validation, data binding changes
506
- - Token, variant, contract, flow, or localization changes
507
- - Changes affecting multiple platforms
508
- - Requests in product/UI terms
509
-
510
- **Platform-first** (skip spec tools):
511
- - Platform-specific polish (iOS-only, Android-only, web-only)
512
- - Local bug fixes that don't alter shared semantic behavior
513
-
514
- ## If spec directories are empty (first-time setup)
515
-
516
- Read \`spec/openuispec-v0.2.md\` from the package first, then:
517
- 1. Scan codebase for UI screens → create \`${specDir}/screens/<name>.yaml\` as \`status: stub\`
518
- 2. Extract tokens (colors, fonts, spacing) → \`${specDir}/tokens/\`
519
- 3. Create contract extensions → \`${specDir}/contracts/\`
520
- 4. Create locale files → \`${specDir}/locales/<locale>.json\`
521
- 5. Fill in \`data_model\`, \`api.endpoints\` in \`${specDir}/openuispec.yaml\`
522
-
523
- ## Rules
524
- - Do not baseline on your own initiative — the user decides when generated output is accepted.
525
- - After generation, always remind the user to review and baseline: \`openuispec drift --snapshot --target <t>\`.
526
- - Do not modify generated UI without checking whether the spec must change first.
527
- - Do not use \`configure-target --defaults\` as silent approval — ask the user to confirm.
528
- - Always read spec format from the installed package, not from cached/memorized content.
529
- ${RULES_END_MARKER}
530
- `;
531
- }
532
-
533
- // ── update-rules ────────────────────────────────────────────────────
534
-
535
- export function updateRules(): void {
536
- const cwd = process.cwd();
537
- const version = getPackageVersion();
538
-
539
- // Detect spec dir from existing openuispec.yaml
540
- let specDir = "openuispec";
541
- for (const candidate of ["openuispec", "spec", "."]) {
542
- if (existsSync(join(cwd, candidate, "openuispec.yaml"))) {
543
- specDir = candidate;
544
- break;
545
- }
546
- }
547
-
548
- // Detect targets from manifest
549
- let targets = [...SUPPORTED_TARGETS];
550
- try {
551
- const manifest = readFileSync(
552
- join(cwd, specDir, "openuispec.yaml"),
553
- "utf-8"
554
- );
555
- const match = manifest.match(/targets:\s*\[([^\]]+)\]/);
556
- if (match) {
557
- targets = match[1].split(",").map((t) => t.trim().replace(/['"]/g, ""));
558
- }
559
- } catch {}
560
-
561
- const rules = aiRulesBlock(specDir, targets);
562
- let updated = 0;
563
-
564
- for (const file of ["CLAUDE.md", "AGENTS.md"]) {
565
- const filePath = join(cwd, file);
566
- if (!existsSync(filePath)) continue;
567
-
568
- const content = readFileSync(filePath, "utf-8");
569
-
570
- // Try marker-based replacement first
571
- const startIdx = content.indexOf(RULES_START_MARKER);
572
- const endIdx = content.indexOf(RULES_END_MARKER);
573
-
574
- if (startIdx !== -1 && endIdx !== -1) {
575
- const before = content.slice(0, startIdx);
576
- const after = content.slice(endIdx + RULES_END_MARKER.length);
577
- writeFileSync(filePath, before + rules.trimStart().trimEnd() + after);
578
- console.log(` updated ${file} (v${version})`);
579
- updated++;
580
- continue;
581
- }
582
-
583
- // Fallback: find the OpenUISpec rules block by header pattern
584
- // The block runs from the header to EOF (it's always the last content)
585
- const headerIdx = content.indexOf("# OpenUISpec — AI Assistant Rules");
586
- if (headerIdx !== -1) {
587
- const before = content.slice(0, headerIdx);
588
- const newContent = before + rules.trimStart().trimEnd() + "\n";
589
- writeFileSync(filePath, newContent);
590
- console.log(` updated ${file} (v${version}, migrated to markers)`);
591
- updated++;
592
- continue;
593
- }
594
-
595
- console.log(` skip ${file} (no OpenUISpec rules block found)`);
596
- }
597
-
598
- if (updated === 0) {
599
- console.log(
600
- "No CLAUDE.md or AGENTS.md with OpenUISpec rules found.\nRun `openuispec init` first."
601
- );
602
- } else {
603
- console.log(`\nAI rules updated to v${version}`);
604
- }
605
-
606
- // Migrate old spec doc version references to current
607
- migrateDocVersionRefs(cwd, specDir);
608
-
609
- // Ensure MCP server is configured
610
- configureMcp(cwd, true);
611
- }
612
-
613
- // ── spec doc version migration ──────────────────────────────────────
614
-
615
- function getCurrentSpecDocVersion(): string | null {
616
- const pkgDir = dirname(fileURLToPath(import.meta.url));
617
- const specDir = join(pkgDir, "..", "spec");
618
- try {
619
- const files = readdirSync(specDir);
620
- const match = files
621
- .map((f) => f.match(/^openuispec-v(.+)\.md$/))
622
- .filter(Boolean)
623
- .sort()
624
- .pop();
625
- return match ? match[1] : null;
626
- } catch {
627
- return null;
628
- }
629
- }
630
-
631
- function migrateDocVersionRefs(cwd: string, specDir: string): void {
632
- const currentVersion = getCurrentSpecDocVersion();
633
- if (!currentVersion) return;
634
-
635
- const currentRef = `openuispec-v${currentVersion}`;
636
- const pattern = /openuispec-v(\d+\.\d+)/g;
637
-
638
- const filesToCheck = [
639
- join(cwd, specDir, "README.md"),
640
- join(cwd, "CLAUDE.md"),
641
- join(cwd, "AGENTS.md"),
642
- ];
643
-
644
- for (const filePath of filesToCheck) {
645
- if (!existsSync(filePath)) continue;
646
-
647
- const content = readFileSync(filePath, "utf-8");
648
- const oldRefs = [...content.matchAll(pattern)]
649
- .filter((m) => m[1] !== currentVersion);
650
- if (oldRefs.length === 0) continue;
651
-
652
- const migrated = content.replace(pattern, (_match, ver) =>
653
- ver === currentVersion ? _match : currentRef
654
- );
655
- writeFileSync(filePath, migrated);
656
- const relPath = relative(cwd, filePath);
657
- const oldVersions = [...new Set(oldRefs.map((m) => m[1]))].join(", ");
658
- console.log(` updated ${relPath} (migrated v${oldVersions} → v${currentVersion} doc references)`);
659
- }
660
- }
661
-
662
- // ── shared MCP config ───────────────────────────────────────────────
663
-
664
- const EXPECTED_MCP_CONFIG = {
665
- command: "openuispec",
666
- args: ["mcp"],
667
- };
668
-
669
- /**
670
- * MCP config files by agent:
671
- * .mcp.json — Claude Code (project scope)
672
- * .vscode/mcp.json — VS Code / Copilot Chat
673
- * .gemini/settings.json — Gemini CLI (if .gemini/ exists)
674
- *
675
- * All use the same { mcpServers: { openuispec: { command, args } } } shape.
676
- */
677
- const JSON_MCP_PATHS = [
678
- ".mcp.json",
679
- join(".vscode", "mcp.json"),
680
- join(".gemini", "settings.json"),
681
- ];
682
-
683
- /** Codex uses TOML: .codex/config.toml */
684
- const CODEX_CONFIG_PATH = join(".codex", "config.toml");
685
- const CODEX_MCP_BLOCK = `\n[mcp_servers.openuispec]\ncommand = "openuispec"\nargs = ["mcp"]\n`;
686
-
687
- function configureCodexMcp(cwd: string, quiet: boolean): boolean {
688
- const codexDir = join(cwd, ".codex");
689
-
690
- const configPath = join(codexDir, "config.toml");
691
- try {
692
- let content = "";
693
- try {
694
- content = readFileSync(configPath, "utf-8");
695
- } catch {
696
- // file doesn't exist — will create
697
- }
698
-
699
- if (content.includes("[mcp_servers.openuispec]")) {
700
- if (!quiet) console.log(` skip ${CODEX_CONFIG_PATH} (openuispec MCP already configured)`);
701
- return false;
702
- }
703
-
704
- if (!existsSync(codexDir)) mkdirSync(codexDir);
705
- writeFileSync(configPath, content + CODEX_MCP_BLOCK);
706
- if (!quiet) console.log(` ${content ? "update" : "create"} ${CODEX_CONFIG_PATH} (MCP server configured)`);
707
- return true;
708
- } catch {
709
- if (!quiet) console.log(` skip ${CODEX_CONFIG_PATH} (could not configure MCP server)`);
710
- return false;
711
- }
712
- }
713
-
714
- function configureMcp(cwd: string, showRestart: boolean, quiet: boolean = false): void {
715
- let changed = false;
716
-
717
- for (const relPath of JSON_MCP_PATHS) {
718
- const configPath = join(cwd, relPath);
719
-
720
- // Optional dirs: only write config if the parent directory already exists
721
- const optionalParent = [".vscode", ".gemini"].find((d) => relPath.startsWith(d));
722
- if (optionalParent && !existsSync(join(cwd, optionalParent))) continue;
723
-
724
- try {
725
- let config: Record<string, any> = {};
726
- try {
727
- config = JSON.parse(readFileSync(configPath, "utf-8"));
728
- } catch {
729
- // file doesn't exist or isn't valid JSON — start fresh
730
- }
731
-
732
- if (!config.mcpServers) config.mcpServers = {};
733
-
734
- const existing = config.mcpServers.openuispec;
735
- const needsUpdate =
736
- !existing ||
737
- existing.command !== EXPECTED_MCP_CONFIG.command ||
738
- JSON.stringify(existing.args) !== JSON.stringify(EXPECTED_MCP_CONFIG.args);
739
-
740
- if (needsUpdate) {
741
- config.mcpServers.openuispec = { ...EXPECTED_MCP_CONFIG };
742
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
743
- if (!quiet) console.log(` ${existing ? "update" : "create"} ${relPath} (MCP server configured)`);
744
- changed = true;
745
- } else {
746
- if (!quiet) console.log(` skip ${relPath} (openuispec MCP already configured)`);
747
- }
748
- } catch {
749
- if (!quiet) console.log(` skip ${relPath} (could not configure MCP server)`);
750
- }
751
- }
752
-
753
- // Codex: .codex/config.toml (TOML format)
754
- if (configureCodexMcp(cwd, quiet)) changed = true;
755
-
756
- if (showRestart && changed) console.log(`\n Restart your AI coding agent to activate the MCP server.`);
757
-
758
- // Clean up stale .claude.json MCP config from older versions
759
- const claudeJsonPath = join(cwd, ".claude.json");
760
- try {
761
- const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
762
- if (claudeJson.mcpServers?.openuispec) {
763
- delete claudeJson.mcpServers.openuispec;
764
- if (Object.keys(claudeJson.mcpServers).length === 0) delete claudeJson.mcpServers;
765
- if (Object.keys(claudeJson).length === 0) {
766
- unlinkSync(claudeJsonPath);
767
- if (!quiet) console.log(` remove .claude.json (migrated MCP config)`);
768
- } else {
769
- writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2) + "\n");
770
- if (!quiet) console.log(` update .claude.json (removed stale MCP config)`);
771
- }
772
- }
773
- } catch {
774
- // .claude.json doesn't exist or not parseable — nothing to clean up
775
- }
776
- }
777
-
778
- /**
779
- * Extract the rules version from a CLAUDE.md / AGENTS.md file.
780
- * Returns null if no version marker is found.
781
- */
782
- export function extractRulesVersion(filePath: string): string | null {
783
- if (!existsSync(filePath)) return null;
784
- const content = readFileSync(filePath, "utf-8");
785
- const match = content.match(
786
- /<!-- openuispec-rules-version:\s*([^\s]+)\s*-->/
787
- );
788
- return match ? match[1] : null;
789
- }
790
-
791
- export { getPackageVersion };
792
-
793
- interface SharedLayerAnswers {
794
- name: string;
795
- platforms: string[];
796
- language: string;
797
- root: string;
798
- tracks: string[];
799
- scope: string;
800
- paths: Record<string, string>;
801
- }
802
-
803
- interface StructureAnswers {
804
- target: string;
805
- root: string;
806
- scope: string;
807
- paths: Record<string, string>;
808
- }
809
-
810
- interface InitOptions {
811
- defaults: boolean;
812
- quiet: boolean;
813
- name?: string;
814
- specDir?: string;
815
- targets?: string[];
816
- withApi?: boolean;
817
- backendPath?: string;
818
- configureTargets?: boolean;
819
- withShared?: boolean;
820
- }
821
-
822
- interface InitAnswers {
823
- name: string;
824
- specDir: string;
825
- targets: string[];
826
- withApi: boolean;
827
- backendPath: string | null;
828
- configureTargets: boolean;
829
- sharedLayers: SharedLayerAnswers[];
830
- structures: StructureAnswers[];
831
- }
832
-
833
- function parseTargetsValue(raw: string): string[] {
834
- return raw
835
- .split(",")
836
- .map((value) => value.trim().toLowerCase())
837
- .filter((value): value is SupportedTarget => isSupportedTarget(value));
838
- }
839
-
840
- function requireFlagValue(argv: string[], index: number, flag: string): string {
841
- const value = argv[index + 1];
842
- if (!value || value.startsWith("--")) {
843
- console.error(`Error: ${flag} requires a value.`);
844
- process.exit(1);
845
- }
846
- return value;
847
- }
848
-
849
- function parseInitArgs(argv: string[]): InitOptions {
850
- const options: InitOptions = { defaults: argv.includes("--defaults"), quiet: argv.includes("--quiet") };
851
-
852
- for (let index = 0; index < argv.length; index++) {
853
- const arg = argv[index];
854
- switch (arg) {
855
- case "--defaults":
856
- case "--quiet":
857
- break;
858
- case "--name":
859
- options.name = requireFlagValue(argv, index, arg);
860
- index++;
861
- break;
862
- case "--spec-dir":
863
- options.specDir = requireFlagValue(argv, index, arg);
864
- index++;
865
- break;
866
- case "--targets":
867
- options.targets = parseTargetsValue(requireFlagValue(argv, index, arg));
868
- index++;
869
- break;
870
- case "--backend":
871
- options.backendPath = requireFlagValue(argv, index, arg);
872
- index++;
873
- break;
874
- case "--with-api":
875
- options.withApi = true;
876
- break;
877
- case "--no-api":
878
- options.withApi = false;
879
- break;
880
- case "--configure-targets":
881
- options.configureTargets = true;
882
- break;
883
- case "--no-configure-targets":
884
- options.configureTargets = false;
885
- break;
886
- case "--with-shared":
887
- options.withShared = true;
888
- break;
889
- case "--no-shared":
890
- options.withShared = false;
891
- break;
892
- default:
893
- if (arg.startsWith("--")) {
894
- console.error(`Error: Unknown init option: ${arg}`);
895
- process.exit(1);
896
- }
897
- }
898
- }
899
-
900
- return options;
901
- }
902
-
903
- function collectDefaults(): InitAnswers {
904
- const cwd = process.cwd();
905
- const defaultName = cwd.split("/").pop() || "MyApp";
906
- return {
907
- name: defaultName,
908
- specDir: "openuispec",
909
- targets: [...SUPPORTED_TARGETS],
910
- withApi: true,
911
- backendPath: "../backend/",
912
- configureTargets: true,
913
- sharedLayers: [],
914
- structures: [],
915
- };
916
- }
917
-
918
- const SHARED_LAYER_DEFAULTS: Record<string, {
919
- language: string;
920
- root: string;
921
- tracks: string[];
922
- scope: string;
923
- paths: Record<string, string>;
924
- structureScope: Record<string, string>;
925
- }> = {
926
- kmp: {
927
- language: "kotlin",
928
- root: "../shared",
929
- tracks: [],
930
- scope: "Business logic, data models, repositories, API clients, view models/stores. No UI rendering.",
931
- paths: { domain: "commonMain/domain/", features: "commonMain/features/" },
932
- structureScope: {
933
- ios: "Pure SwiftUI views and navigation. All business logic comes from the shared layer.",
934
- android: "Pure Compose UI and navigation. All business logic comes from the shared layer.",
935
- },
936
- },
937
- };
938
-
939
- function defaultSharedConfig(targets: string[]): {
940
- sharedLayers: SharedLayerAnswers[];
941
- structures: StructureAnswers[];
942
- } {
943
- const mobilePlatforms = targets.filter((t) => t === "ios" || t === "android");
944
- if (mobilePlatforms.length < 2) {
945
- return { sharedLayers: [], structures: [] };
946
- }
947
-
948
- const preset = SHARED_LAYER_DEFAULTS.kmp;
949
- const sharedLayers: SharedLayerAnswers[] = [{
950
- name: "mobile_common",
951
- platforms: mobilePlatforms,
952
- language: preset.language,
953
- root: preset.root,
954
- tracks: preset.tracks,
955
- scope: preset.scope,
956
- paths: preset.paths,
957
- }];
958
-
959
- const structures: StructureAnswers[] = mobilePlatforms.map((t) => ({
960
- target: t,
961
- root: preset.root,
962
- scope: preset.structureScope[t] ?? `Pure ${t} UI. Business logic comes from the shared layer.`,
963
- paths: { ui: `${t}App/ui/` },
964
- }));
965
-
966
- return { sharedLayers, structures };
967
- }
968
-
969
- async function collectSharedLayerAnswers(
970
- rl: ReturnType<typeof createInterface>,
971
- targets: string[],
972
- ): Promise<{ sharedLayers: SharedLayerAnswers[]; structures: StructureAnswers[] }> {
973
- const withShared = await askYesNo(rl, "\nShare code between platforms (e.g. KMP commonMain)?", false);
974
- if (!withShared) return { sharedLayers: [], structures: [] };
975
-
976
- const defaults = defaultSharedConfig(targets);
977
- const defaultLayer = defaults.sharedLayers[0];
978
- if (!defaultLayer) return { sharedLayers: [], structures: [] };
979
-
980
- console.log("\n Shared layer defaults (KMP):");
981
- console.log(` platforms: ${defaultLayer.platforms.join(", ")}`);
982
- console.log(` language: ${defaultLayer.language}`);
983
- console.log(` root: ${defaultLayer.root}`);
984
- console.log(` scope: ${defaultLayer.scope}`);
985
-
986
- const useDefaults = await askYesNo(rl, " Use these defaults?", true);
987
- if (useDefaults) return defaults;
988
-
989
- const layerName = await ask(rl, " Shared layer name", defaultLayer.name);
990
- const platformsRaw = await askList(rl, " Platforms", targets, defaultLayer.platforms);
991
- const language = await ask(rl, " Language", defaultLayer.language);
992
- const root = await ask(rl, " Root path (relative to openuispec.yaml)", defaultLayer.root);
993
- const scope = await ask(rl, " Scope (what code belongs here)", defaultLayer.scope);
994
- const wantTracks = await askYesNo(rl, " Enable hash-based drift tracking for this layer?", false);
995
- const tracksRaw = wantTracks
996
- ? await askList(
997
- rl,
998
- " Tracked spec categories",
999
- ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"],
1000
- ["manifest", "contracts", "flows"]
1001
- )
1002
- : [];
1003
-
1004
- const sharedLayers: SharedLayerAnswers[] = [{
1005
- name: layerName,
1006
- platforms: platformsRaw,
1007
- language,
1008
- root,
1009
- tracks: tracksRaw,
1010
- scope,
1011
- paths: defaultLayer.paths,
1012
- }];
1013
-
1014
- const structures: StructureAnswers[] = [];
1015
- for (const t of platformsRaw) {
1016
- const defaultStructure = defaults.structures.find((s) => s.target === t);
1017
- const structRoot = await ask(rl, ` ${t} structure root`, defaultStructure?.root ?? root);
1018
- const structScope = await ask(
1019
- rl,
1020
- ` ${t} scope (what code belongs in the ${t} target)`,
1021
- defaultStructure?.scope ?? `Pure ${t} UI rendering.`
1022
- );
1023
- structures.push({
1024
- target: t,
1025
- root: structRoot,
1026
- scope: structScope,
1027
- paths: defaultStructure?.paths ?? { ui: `${t}App/ui/` },
1028
- });
1029
- }
1030
-
1031
- return { sharedLayers, structures };
1032
- }
1033
-
1034
- async function collectInteractiveAnswers(rl: ReturnType<typeof createInterface>): Promise<InitAnswers> {
1035
- const defaults = collectDefaults();
1036
- const name = await ask(rl, "Project name", defaults.name);
1037
- const specDir = await ask(rl, "Spec directory", defaults.specDir);
1038
- const targets = await askList(rl, "\nWhich platforms?", [...SUPPORTED_TARGETS], defaults.targets);
1039
-
1040
- if (targets.length === 0) {
1041
- console.error("At least one target is required.");
1042
- process.exit(1);
1043
- }
1044
-
1045
- const withApi = await askYesNo(rl, "Will this spec declare API endpoints?", defaults.withApi);
1046
- const backendPath = withApi
1047
- ? await ask(rl, "Backend folder path relative to openuispec.yaml", defaults.backendPath ?? "../backend/")
1048
- : null;
1049
- const configureTargets = await askYesNo(rl, "Configure target stacks now?", defaults.configureTargets);
1050
- const { sharedLayers, structures } = await collectSharedLayerAnswers(rl, targets);
1051
-
1052
- return {
1053
- name,
1054
- specDir,
1055
- targets,
1056
- withApi,
1057
- backendPath,
1058
- configureTargets,
1059
- sharedLayers,
1060
- structures,
1061
- };
1062
- }
1063
-
1064
- function collectNonInteractiveAnswers(argv: string[]): InitAnswers {
1065
- const parsed = parseInitArgs(argv);
1066
- const defaults = collectDefaults();
1067
-
1068
- if (!parsed.defaults && argv.filter((a) => a !== "--quiet").length === 0) {
1069
- console.error(
1070
- "Error: `openuispec init` needs a TTY for prompts.\n" +
1071
- "Run with `--list-options` to get prompt definitions as JSON, or pass flags such as `--name`, `--targets`, `--with-api`, `--backend`, and `--configure-targets`."
1072
- );
1073
- process.exit(1);
1074
- }
1075
-
1076
- const targets = parsed.targets && parsed.targets.length > 0 ? parsed.targets : defaults.targets;
1077
- if (targets.length === 0) {
1078
- console.error("Error: --targets must include at least one of ios, android, web.");
1079
- process.exit(1);
1080
- }
1081
-
1082
- const withApi = parsed.withApi ?? defaults.withApi;
1083
- const backendPath = withApi ? parsed.backendPath ?? defaults.backendPath : null;
1084
- const withShared = parsed.withShared ?? false;
1085
- const { sharedLayers, structures } = withShared ? defaultSharedConfig(targets) : { sharedLayers: [], structures: [] };
1086
-
1087
- return {
1088
- name: parsed.name ?? defaults.name,
1089
- specDir: parsed.specDir ?? defaults.specDir,
1090
- targets,
1091
- withApi,
1092
- backendPath,
1093
- configureTargets: parsed.configureTargets ?? defaults.configureTargets,
1094
- sharedLayers,
1095
- structures,
1096
- };
1097
- }
1098
-
1099
- // ── main ─────────────────────────────────────────────────────────────
1100
-
1101
- export async function init(argv: string[] = []): Promise<void> {
1102
- if (argv.includes("--list-options")) {
1103
- console.log(JSON.stringify(listInitOptions(), null, 2));
1104
- return;
1105
- }
1106
-
1107
- const quiet = argv.includes("--quiet");
1108
- const interactive = stdin.isTTY && stdout.isTTY && !argv.includes("--defaults");
1109
- const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
1110
-
1111
- if (!quiet) console.log("\nOpenUISpec — Project Setup\n");
1112
-
1113
- try {
1114
- const cwd = process.cwd();
1115
- const answers = rl ? await collectInteractiveAnswers(rl) : collectNonInteractiveAnswers(argv);
1116
- rl?.close();
1117
-
1118
- // ── create folders ─────────────────────────────────────────────
1119
-
1120
- if (!quiet) console.log("\nScaffolding...\n");
1121
-
1122
- const root = join(cwd, answers.specDir);
1123
- const dirs = [
1124
- "tokens",
1125
- "contracts",
1126
- "components",
1127
- "screens",
1128
- "flows",
1129
- "platform",
1130
- "locales",
1131
- ];
1132
-
1133
- ensureDir(root);
1134
- for (const d of dirs) {
1135
- ensureDir(join(root, d));
1136
- }
1137
-
1138
- // ── manifest ───────────────────────────────────────────────────
1139
-
1140
- writeIfMissing(
1141
- join(root, "openuispec.yaml"),
1142
- manifestTemplate(answers.name, answers.targets, {
1143
- withApi: answers.withApi,
1144
- backendPath: answers.backendPath,
1145
- sharedLayers: answers.sharedLayers,
1146
- structures: answers.structures,
1147
- }),
1148
- quiet
1149
- );
1150
-
1151
- // ── spec README ──────────────────────────────────────────────
1152
-
1153
- writeIfMissing(
1154
- join(root, "README.md"),
1155
- specReadmeTemplate(answers.name, answers.targets),
1156
- quiet
1157
- );
1158
-
1159
- // ── .gitkeep for empty dirs ────────────────────────────────────
1160
-
1161
- for (const d of dirs) {
1162
- const dir = join(root, d);
1163
- const entries = existsSync(dir)
1164
- ? readdirSync(dir).filter((f) => f !== ".gitkeep")
1165
- : [];
1166
- if (entries.length === 0) {
1167
- const gk = join(dir, ".gitkeep");
1168
- if (!existsSync(gk)) {
1169
- writeFileSync(gk, "");
1170
- if (!quiet) console.log(` create ${relative(cwd, gk)}`);
1171
- }
1172
- }
1173
- }
1174
-
1175
- // ── AI assistant rules ─────────────────────────────────────────
1176
-
1177
- const rules = aiRulesBlock(answers.specDir, answers.targets);
1178
-
1179
- for (const file of ["CLAUDE.md", "AGENTS.md"]) {
1180
- const filePath = join(cwd, file);
1181
- if (existsSync(filePath)) {
1182
- const existing = readFileSync(filePath, "utf-8");
1183
- if (existing.includes("OpenUISpec")) {
1184
- if (!quiet) console.log(` skip ${file} (already has OpenUISpec rules)`);
1185
- continue;
1186
- }
1187
- appendFileSync(filePath, "\n" + rules);
1188
- if (!quiet) console.log(` update ${file} (appended rules)`);
1189
- } else {
1190
- writeFileSync(filePath, rules.trimStart());
1191
- if (!quiet) console.log(` create ${file}`);
1192
- }
1193
- }
1194
-
1195
- // ── MCP server configuration ────────────────────────────────────
1196
-
1197
- configureMcp(cwd, false, quiet);
1198
-
1199
- if (answers.configureTargets) {
1200
- if (!quiet) console.log("\nConfiguring target stacks...\n");
1201
- const { runConfigureTarget } = await import("./configure-target.js");
1202
- for (const target of answers.targets) {
1203
- await runConfigureTarget([target, ...(interactive ? [] : ["--defaults"]), ...(quiet ? ["--silent"] : [])]);
1204
- }
1205
- }
1206
-
1207
- // ── done ───────────────────────────────────────────────────────
1208
-
1209
- if (quiet) {
1210
- console.log(`./${answers.specDir}/`);
1211
- } else {
1212
- console.log(`
1213
- Done! Your spec project is ready at ./${answers.specDir}/
1214
-
1215
- Getting started (new project):
1216
- 1. Edit ${answers.specDir}/openuispec.yaml — define your data model and API
1217
- 2. Create tokens in ${answers.specDir}/tokens/ (colors, typography, spacing, etc.)
1218
- 3. Create contract extensions in ${answers.specDir}/contracts/ (visual variants for the 7 built-in contracts)
1219
- 4. Create screens in ${answers.specDir}/screens/ (one YAML per screen)
1220
- 5. Create flows in ${answers.specDir}/flows/ (multi-step navigation)
1221
- 6. Create locale files in ${answers.specDir}/locales/ (one JSON per supported locale)
1222
- 7. Run \`openuispec validate\` and \`openuispec validate semantic\` to check everything
1223
- 8. Ask AI to generate native code from the spec
1224
- 9. Run \`openuispec drift --snapshot --target ${answers.targets[0]}\` to baseline the first accepted target state after that target output directory exists
1225
-
1226
- Getting started (existing project):
1227
- 1. Ask AI to read your existing UI code and generate spec files:
1228
- "Read src/screens/HomeScreen.swift and create ${answers.specDir}/screens/home.yaml as status: stub"
1229
- 2. Spec screens incrementally: stub → draft → ready
1230
- 3. Only ready/draft screens are tracked by drift detection
1231
- 4. Run \`openuispec validate\` to check specs against the schema
1232
- 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
1233
-
1234
- Commands:
1235
- openuispec validate Validate spec files
1236
- openuispec validate semantic Check semantic cross-references
1237
- openuispec configure-target ios [--defaults] Configure target stack; --defaults stays unconfirmed
1238
- openuispec status Show cross-target baseline/drift status
1239
- openuispec drift --target ios --explain Explain semantic spec changes
1240
- openuispec prepare --target ios Build the target work bundle
1241
- openuispec drift --snapshot --target ios Save current state + git baseline after target output exists
1242
-
1243
- AI rules have been added to CLAUDE.md and AGENTS.md.
1244
- MCP server configured (AI assistants will use openuispec tools automatically).
1245
-
1246
- Docs: https://openuispec.rsteam.uz
1247
- `);
1248
- }
1249
- } catch (err) {
1250
- rl?.close();
1251
- throw err;
1252
- }
1253
- }