openuispec 0.2.19 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,1047 @@
|
|
|
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 \`puppeteer\`). |
|
|
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 — AI Assistant Rules
|
|
305
|
+
# ================================
|
|
306
|
+
# This project uses OpenUISpec to define UI as a semantic spec.
|
|
307
|
+
# Spec files are the single source of truth for all UI across platforms.
|
|
308
|
+
# Targets: ${targetList}
|
|
309
|
+
|
|
310
|
+
## MANDATORY — UI work requires OpenUISpec tools
|
|
311
|
+
|
|
312
|
+
When the user's request involves UI — screens, navigation, layout, tokens, flows, localization,
|
|
313
|
+
or any visual/structural change — you MUST use the OpenUISpec tools before writing any code.
|
|
314
|
+
|
|
315
|
+
### MCP Tools (use these when available)
|
|
316
|
+
|
|
317
|
+
Call these MCP tools directly. They return structured JSON with everything you need.
|
|
318
|
+
|
|
319
|
+
**Pre-generation:**
|
|
320
|
+
1. Call \`openuispec_prepare\` with the target platform — returns spec context, platform config, constraints.
|
|
321
|
+
Use \`include_specs: true\` to embed all spec contents in one call (saves a separate read_specs).
|
|
322
|
+
2. Call \`openuispec_read_specs\` to load spec file contents if not using include_specs.
|
|
323
|
+
Without paths: returns file listing. With paths: returns contents. Use these as the AUTHORITATIVE source.
|
|
324
|
+
3. If spec changes are needed, update spec files FIRST, then call \`openuispec_check\`.
|
|
325
|
+
4. Generate or update the platform UI code based on the spec contents.
|
|
326
|
+
|
|
327
|
+
**Post-generation (EVERY TIME after writing UI code):**
|
|
328
|
+
5. Call \`openuispec_check\` to validate spec files (schema + semantics) and confirm prepare readiness.
|
|
329
|
+
Note: this validates the SPEC, not the generated code.
|
|
330
|
+
6. Call \`openuispec_check\` with \`audit: true\` to get a spec-derived checklist, then manually review
|
|
331
|
+
the generated code against it. For each screen, verify:
|
|
332
|
+
- Every field/action in the spec has a corresponding UI element
|
|
333
|
+
- Token values (colors, spacing, radii) match exactly — no approximations
|
|
334
|
+
- Contract \`must_handle\` states are all implemented (loading, error, empty, etc.)
|
|
335
|
+
- Adaptive breakpoints match the spec's \`size_classes\`
|
|
336
|
+
- Locale keys match \`$t:\` references
|
|
337
|
+
- Navigation targets match flow definitions
|
|
338
|
+
7. Report any real gaps found and fix them before finishing.
|
|
339
|
+
|
|
340
|
+
**Iterating before baseline:**
|
|
341
|
+
Generated code rarely needs just one pass. Read the spec, audit the generated code against it,
|
|
342
|
+
take screenshots to verify visuals, then fix gaps and repeat.
|
|
343
|
+
Multiple generate → review → fix cycles are expected before the user accepts the result.
|
|
344
|
+
|
|
345
|
+
**Baseline reminder:**
|
|
346
|
+
After generation, remind the user to review the output and run the baseline when satisfied:
|
|
347
|
+
> When you're happy with the generated output, run: \`openuispec drift --snapshot --target <t>\`
|
|
348
|
+
> This records the spec state so future changes are tracked as incremental drift.
|
|
349
|
+
Do not baseline on your own initiative — only run the snapshot when the user asks.
|
|
350
|
+
|
|
351
|
+
**Creating new spec files:**
|
|
352
|
+
- Call \`openuispec_spec_types\` to discover available spec types.
|
|
353
|
+
- Call \`openuispec_spec_schema\` with the specific type to get the full JSON schema.
|
|
354
|
+
- Write the spec file following the schema exactly.
|
|
355
|
+
|
|
356
|
+
**Focused getters (prefer these for incremental edits over \`read_specs\`):**
|
|
357
|
+
- \`openuispec_get_screen(name)\` — single screen spec
|
|
358
|
+
- \`openuispec_get_contract(name, variant?)\` — single contract, optionally one variant
|
|
359
|
+
- \`openuispec_get_component(name, variant?)\` — single component, optionally one variant
|
|
360
|
+
- \`openuispec_get_tokens(category)\` — single token category (color, typography, spacing, etc.)
|
|
361
|
+
- \`openuispec_get_locale(locale, keys?)\` — single locale file, optionally filtered keys
|
|
362
|
+
- \`openuispec_check(target, audit?, screens?, contracts?)\` — validation + optional scoped audit checklist
|
|
363
|
+
|
|
364
|
+
Use \`read_specs\` for full-project generation; use focused getters when editing one screen or contract.
|
|
365
|
+
|
|
366
|
+
**Other tools:**
|
|
367
|
+
- \`openuispec_status\` — cross-target summary, good starting point
|
|
368
|
+
- \`openuispec_drift\` with \`explain: true\` — property-level spec changes
|
|
369
|
+
- \`openuispec_validate\` — schema-only validation by group
|
|
370
|
+
|
|
371
|
+
### CLI fallback (when MCP is not available)
|
|
372
|
+
|
|
373
|
+
If MCP tools are not available, use these CLI commands with \`--json\` flag:
|
|
374
|
+
|
|
375
|
+
**Status & discovery:**
|
|
376
|
+
- \`openuispec status --json\` — cross-target status
|
|
377
|
+
- \`openuispec spec-types\` — list available spec types
|
|
378
|
+
- \`openuispec spec-schema <type>\` — get JSON schema for a spec type
|
|
379
|
+
|
|
380
|
+
**Spec access:**
|
|
381
|
+
- \`openuispec read-specs [paths...]\` — read spec file contents
|
|
382
|
+
- \`openuispec get-screen <name>\` — get a single screen spec
|
|
383
|
+
- \`openuispec get-contract <name> [--variant v]\` — get a contract spec
|
|
384
|
+
- \`openuispec get-component <name> [--variant v]\` — get a component spec
|
|
385
|
+
- \`openuispec get-tokens <category>\` — get tokens for a category
|
|
386
|
+
- \`openuispec get-locale <locale> [--keys k1,k2]\` — get a locale file
|
|
387
|
+
|
|
388
|
+
**Validation & generation workflow:**
|
|
389
|
+
- \`openuispec validate [group...] --json\` — validate spec files against JSON Schemas
|
|
390
|
+
- \`openuispec check --target <t> --json\` — validate spec files + check target generation readiness
|
|
391
|
+
- \`openuispec prepare --target <t> --json\` — build AI-ready work bundle
|
|
392
|
+
- \`openuispec drift --target <t> --explain --json\` — semantic drift
|
|
393
|
+
|
|
394
|
+
**Visual verification:**
|
|
395
|
+
- \`openuispec screenshot --route /path\` — screenshot the web app
|
|
396
|
+
- \`openuispec screenshot --route /path --init-script "..."\` — inject auth/role before rendering (web only; app must implement \`__ous_init\` bootstrapper)
|
|
397
|
+
- \`openuispec screenshot-android [--project-dir path]\` — screenshot Android app
|
|
398
|
+
- \`openuispec screenshot-ios [--project-dir path]\` — screenshot iOS app
|
|
399
|
+
|
|
400
|
+
### Other CLI commands
|
|
401
|
+
- \`openuispec init\` — scaffold a new spec project
|
|
402
|
+
- \`openuispec configure-target <t>\` — configure target platform stack
|
|
403
|
+
- \`openuispec update-rules\` — update AI rules to match installed package version
|
|
404
|
+
- \`openuispec drift --snapshot --target <t>\` — snapshot current state (user-initiated, after reviewing generated output)
|
|
405
|
+
|
|
406
|
+
## Spec format reference
|
|
407
|
+
|
|
408
|
+
The spec format, schemas, and generation rules are in the installed \`openuispec\` package.
|
|
409
|
+
You MUST read the reference files before creating or editing spec files — do NOT guess the format.
|
|
410
|
+
|
|
411
|
+
**Find the package:** \`node_modules/openuispec/\` or run \`npm root -g\` → \`<prefix>/openuispec/\`.
|
|
412
|
+
**Online fallback:** \`https://openuispec.rsteam.uz/llms-full.txt\`
|
|
413
|
+
|
|
414
|
+
**Reference files (read in order):**
|
|
415
|
+
1. \`README.md\` — schema tables, file format, root wrapper keys
|
|
416
|
+
2. \`spec/openuispec-v0.2.md\` — full specification
|
|
417
|
+
3. \`examples/taskflow/openuispec/\` — complete working example
|
|
418
|
+
4. \`schema/\` — JSON Schemas for every file type
|
|
419
|
+
|
|
420
|
+
## Spec location
|
|
421
|
+
- Spec root: \`${specDir}/\` — read \`${specDir}/openuispec.yaml\` first for actual paths.
|
|
422
|
+
- Default dirs: tokens/, screens/, flows/, contracts/, components/, platform/, locales/
|
|
423
|
+
|
|
424
|
+
## When to start from spec vs platform code
|
|
425
|
+
|
|
426
|
+
**Spec-first** (use \`openuispec_prepare\` or \`openuispec prepare\`):
|
|
427
|
+
- Screen structure, navigation, fields, actions, validation, data binding changes
|
|
428
|
+
- Token, variant, contract, flow, or localization changes
|
|
429
|
+
- Changes affecting multiple platforms
|
|
430
|
+
- Requests in product/UI terms
|
|
431
|
+
|
|
432
|
+
**Platform-first** (skip spec tools):
|
|
433
|
+
- Platform-specific polish (iOS-only, Android-only, web-only)
|
|
434
|
+
- Local bug fixes that don't alter shared semantic behavior
|
|
435
|
+
|
|
436
|
+
## If spec directories are empty (first-time setup)
|
|
437
|
+
|
|
438
|
+
Read \`spec/openuispec-v0.2.md\` from the package first, then:
|
|
439
|
+
1. Scan codebase for UI screens → create \`${specDir}/screens/<name>.yaml\` as \`status: stub\`
|
|
440
|
+
2. Extract tokens (colors, fonts, spacing) → \`${specDir}/tokens/\`
|
|
441
|
+
3. Create contract extensions → \`${specDir}/contracts/\`
|
|
442
|
+
4. Create locale files → \`${specDir}/locales/<locale>.json\`
|
|
443
|
+
5. Fill in \`data_model\`, \`api.endpoints\` in \`${specDir}/openuispec.yaml\`
|
|
444
|
+
|
|
445
|
+
## Rules
|
|
446
|
+
- Do not baseline on your own initiative — the user decides when generated output is accepted.
|
|
447
|
+
- After generation, always remind the user to review and baseline: \`openuispec drift --snapshot --target <t>\`.
|
|
448
|
+
- Do not modify generated UI without checking whether the spec must change first.
|
|
449
|
+
- Do not use \`configure-target --defaults\` as silent approval — ask the user to confirm.
|
|
450
|
+
- Always read spec format from the installed package, not from cached/memorized content.
|
|
451
|
+
${RULES_END_MARKER}
|
|
452
|
+
`;
|
|
453
|
+
}
|
|
454
|
+
// ── update-rules ────────────────────────────────────────────────────
|
|
455
|
+
export function updateRules() {
|
|
456
|
+
const cwd = process.cwd();
|
|
457
|
+
const version = getPackageVersion();
|
|
458
|
+
// Detect spec dir from existing openuispec.yaml
|
|
459
|
+
let specDir = "openuispec";
|
|
460
|
+
for (const candidate of ["openuispec", "spec", "."]) {
|
|
461
|
+
if (existsSync(join(cwd, candidate, "openuispec.yaml"))) {
|
|
462
|
+
specDir = candidate;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Detect targets from manifest
|
|
467
|
+
let targets = [...SUPPORTED_TARGETS];
|
|
468
|
+
try {
|
|
469
|
+
const manifest = readFileSync(join(cwd, specDir, "openuispec.yaml"), "utf-8");
|
|
470
|
+
const match = manifest.match(/targets:\s*\[([^\]]+)\]/);
|
|
471
|
+
if (match) {
|
|
472
|
+
const parsedTargets = match[1]
|
|
473
|
+
.split(",")
|
|
474
|
+
.map((t) => t.trim().replace(/['"]/g, ""))
|
|
475
|
+
.filter(isSupportedTarget);
|
|
476
|
+
if (parsedTargets.length > 0) {
|
|
477
|
+
targets = parsedTargets;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
catch { }
|
|
482
|
+
const rules = aiRulesBlock(specDir, targets);
|
|
483
|
+
let updated = 0;
|
|
484
|
+
for (const file of ["CLAUDE.md", "AGENTS.md"]) {
|
|
485
|
+
const filePath = join(cwd, file);
|
|
486
|
+
if (!existsSync(filePath))
|
|
487
|
+
continue;
|
|
488
|
+
const content = readFileSync(filePath, "utf-8");
|
|
489
|
+
// Try marker-based replacement first
|
|
490
|
+
const startIdx = content.indexOf(RULES_START_MARKER);
|
|
491
|
+
const endIdx = content.indexOf(RULES_END_MARKER);
|
|
492
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
493
|
+
const before = content.slice(0, startIdx);
|
|
494
|
+
const after = content.slice(endIdx + RULES_END_MARKER.length);
|
|
495
|
+
writeFileSync(filePath, before + rules.trimStart().trimEnd() + after);
|
|
496
|
+
console.log(` updated ${file} (v${version})`);
|
|
497
|
+
updated++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
// Fallback: find the OpenUISpec rules block by header pattern
|
|
501
|
+
// The block runs from the header to EOF (it's always the last content)
|
|
502
|
+
const headerIdx = content.indexOf("# OpenUISpec — AI Assistant Rules");
|
|
503
|
+
if (headerIdx !== -1) {
|
|
504
|
+
const before = content.slice(0, headerIdx);
|
|
505
|
+
const newContent = before + rules.trimStart().trimEnd() + "\n";
|
|
506
|
+
writeFileSync(filePath, newContent);
|
|
507
|
+
console.log(` updated ${file} (v${version}, migrated to markers)`);
|
|
508
|
+
updated++;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
console.log(` skip ${file} (no OpenUISpec rules block found)`);
|
|
512
|
+
}
|
|
513
|
+
if (updated === 0) {
|
|
514
|
+
console.log("No CLAUDE.md or AGENTS.md with OpenUISpec rules found.\nRun `openuispec init` first.");
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
console.log(`\nAI rules updated to v${version}`);
|
|
518
|
+
}
|
|
519
|
+
// Migrate old spec doc version references to current
|
|
520
|
+
migrateDocVersionRefs(cwd, specDir);
|
|
521
|
+
// Ensure MCP server is configured
|
|
522
|
+
configureMcp(cwd, true);
|
|
523
|
+
}
|
|
524
|
+
// ── spec doc version migration ──────────────────────────────────────
|
|
525
|
+
function getCurrentSpecDocVersion() {
|
|
526
|
+
const specDir = resolvePackagePath(import.meta.url, "spec");
|
|
527
|
+
try {
|
|
528
|
+
const files = readdirSync(specDir);
|
|
529
|
+
const match = files
|
|
530
|
+
.map((f) => f.match(/^openuispec-v(.+)\.md$/))
|
|
531
|
+
.filter(Boolean)
|
|
532
|
+
.sort()
|
|
533
|
+
.pop();
|
|
534
|
+
return match ? match[1] : null;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function migrateDocVersionRefs(cwd, specDir) {
|
|
541
|
+
const currentVersion = getCurrentSpecDocVersion();
|
|
542
|
+
if (!currentVersion)
|
|
543
|
+
return;
|
|
544
|
+
const currentRef = `openuispec-v${currentVersion}`;
|
|
545
|
+
const pattern = /openuispec-v(\d+\.\d+)/g;
|
|
546
|
+
const filesToCheck = [
|
|
547
|
+
join(cwd, specDir, "README.md"),
|
|
548
|
+
join(cwd, "CLAUDE.md"),
|
|
549
|
+
join(cwd, "AGENTS.md"),
|
|
550
|
+
];
|
|
551
|
+
for (const filePath of filesToCheck) {
|
|
552
|
+
if (!existsSync(filePath))
|
|
553
|
+
continue;
|
|
554
|
+
const content = readFileSync(filePath, "utf-8");
|
|
555
|
+
const oldRefs = [...content.matchAll(pattern)]
|
|
556
|
+
.filter((m) => m[1] !== currentVersion);
|
|
557
|
+
if (oldRefs.length === 0)
|
|
558
|
+
continue;
|
|
559
|
+
const migrated = content.replace(pattern, (_match, ver) => ver === currentVersion ? _match : currentRef);
|
|
560
|
+
writeFileSync(filePath, migrated);
|
|
561
|
+
const relPath = relative(cwd, filePath);
|
|
562
|
+
const oldVersions = [...new Set(oldRefs.map((m) => m[1]))].join(", ");
|
|
563
|
+
console.log(` updated ${relPath} (migrated v${oldVersions} → v${currentVersion} doc references)`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// ── shared MCP config ───────────────────────────────────────────────
|
|
567
|
+
const EXPECTED_MCP_CONFIG = {
|
|
568
|
+
command: "openuispec",
|
|
569
|
+
args: ["mcp"],
|
|
570
|
+
};
|
|
571
|
+
/**
|
|
572
|
+
* MCP config files by agent:
|
|
573
|
+
* .mcp.json — Claude Code (project scope)
|
|
574
|
+
* .vscode/mcp.json — VS Code / Copilot Chat
|
|
575
|
+
* .gemini/settings.json — Gemini CLI (if .gemini/ exists)
|
|
576
|
+
*
|
|
577
|
+
* All use the same { mcpServers: { openuispec: { command, args } } } shape.
|
|
578
|
+
*/
|
|
579
|
+
const JSON_MCP_PATHS = [
|
|
580
|
+
".mcp.json",
|
|
581
|
+
join(".vscode", "mcp.json"),
|
|
582
|
+
join(".gemini", "settings.json"),
|
|
583
|
+
];
|
|
584
|
+
/** Codex uses TOML: .codex/config.toml */
|
|
585
|
+
const CODEX_CONFIG_PATH = join(".codex", "config.toml");
|
|
586
|
+
const CODEX_MCP_BLOCK = `\n[mcp_servers.openuispec]\ncommand = "openuispec"\nargs = ["mcp"]\n`;
|
|
587
|
+
function configureCodexMcp(cwd, quiet) {
|
|
588
|
+
const codexDir = join(cwd, ".codex");
|
|
589
|
+
const configPath = join(codexDir, "config.toml");
|
|
590
|
+
try {
|
|
591
|
+
let content = "";
|
|
592
|
+
try {
|
|
593
|
+
content = readFileSync(configPath, "utf-8");
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
// file doesn't exist — will create
|
|
597
|
+
}
|
|
598
|
+
if (content.includes("[mcp_servers.openuispec]")) {
|
|
599
|
+
if (!quiet)
|
|
600
|
+
console.log(` skip ${CODEX_CONFIG_PATH} (openuispec MCP already configured)`);
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
if (!existsSync(codexDir))
|
|
604
|
+
mkdirSync(codexDir);
|
|
605
|
+
writeFileSync(configPath, content + CODEX_MCP_BLOCK);
|
|
606
|
+
if (!quiet)
|
|
607
|
+
console.log(` ${content ? "update" : "create"} ${CODEX_CONFIG_PATH} (MCP server configured)`);
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
if (!quiet)
|
|
612
|
+
console.log(` skip ${CODEX_CONFIG_PATH} (could not configure MCP server)`);
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function configureMcp(cwd, showRestart, quiet = false) {
|
|
617
|
+
let changed = false;
|
|
618
|
+
for (const relPath of JSON_MCP_PATHS) {
|
|
619
|
+
const configPath = join(cwd, relPath);
|
|
620
|
+
// Optional dirs: only write config if the parent directory already exists
|
|
621
|
+
const optionalParent = [".vscode", ".gemini"].find((d) => relPath.startsWith(d));
|
|
622
|
+
if (optionalParent && !existsSync(join(cwd, optionalParent)))
|
|
623
|
+
continue;
|
|
624
|
+
try {
|
|
625
|
+
let config = {};
|
|
626
|
+
try {
|
|
627
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// file doesn't exist or isn't valid JSON — start fresh
|
|
631
|
+
}
|
|
632
|
+
if (!config.mcpServers)
|
|
633
|
+
config.mcpServers = {};
|
|
634
|
+
const existing = config.mcpServers.openuispec;
|
|
635
|
+
const needsUpdate = !existing ||
|
|
636
|
+
existing.command !== EXPECTED_MCP_CONFIG.command ||
|
|
637
|
+
JSON.stringify(existing.args) !== JSON.stringify(EXPECTED_MCP_CONFIG.args);
|
|
638
|
+
if (needsUpdate) {
|
|
639
|
+
config.mcpServers.openuispec = { ...EXPECTED_MCP_CONFIG };
|
|
640
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
641
|
+
if (!quiet)
|
|
642
|
+
console.log(` ${existing ? "update" : "create"} ${relPath} (MCP server configured)`);
|
|
643
|
+
changed = true;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
if (!quiet)
|
|
647
|
+
console.log(` skip ${relPath} (openuispec MCP already configured)`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
if (!quiet)
|
|
652
|
+
console.log(` skip ${relPath} (could not configure MCP server)`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// Codex: .codex/config.toml (TOML format)
|
|
656
|
+
if (configureCodexMcp(cwd, quiet))
|
|
657
|
+
changed = true;
|
|
658
|
+
if (showRestart && changed)
|
|
659
|
+
console.log(`\n Restart your AI coding agent to activate the MCP server.`);
|
|
660
|
+
// Clean up stale .claude.json MCP config from older versions
|
|
661
|
+
const claudeJsonPath = join(cwd, ".claude.json");
|
|
662
|
+
try {
|
|
663
|
+
const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
664
|
+
if (claudeJson.mcpServers?.openuispec) {
|
|
665
|
+
delete claudeJson.mcpServers.openuispec;
|
|
666
|
+
if (Object.keys(claudeJson.mcpServers).length === 0)
|
|
667
|
+
delete claudeJson.mcpServers;
|
|
668
|
+
if (Object.keys(claudeJson).length === 0) {
|
|
669
|
+
unlinkSync(claudeJsonPath);
|
|
670
|
+
if (!quiet)
|
|
671
|
+
console.log(` remove .claude.json (migrated MCP config)`);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2) + "\n");
|
|
675
|
+
if (!quiet)
|
|
676
|
+
console.log(` update .claude.json (removed stale MCP config)`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// .claude.json doesn't exist or not parseable — nothing to clean up
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Extract the rules version from a CLAUDE.md / AGENTS.md file.
|
|
686
|
+
* Returns null if no version marker is found.
|
|
687
|
+
*/
|
|
688
|
+
export function extractRulesVersion(filePath) {
|
|
689
|
+
if (!existsSync(filePath))
|
|
690
|
+
return null;
|
|
691
|
+
const content = readFileSync(filePath, "utf-8");
|
|
692
|
+
const match = content.match(/<!-- openuispec-rules-version:\s*([^\s]+)\s*-->/);
|
|
693
|
+
return match ? match[1] : null;
|
|
694
|
+
}
|
|
695
|
+
export { getPackageVersion };
|
|
696
|
+
function parseTargetsValue(raw) {
|
|
697
|
+
return raw
|
|
698
|
+
.split(",")
|
|
699
|
+
.map((value) => value.trim().toLowerCase())
|
|
700
|
+
.filter((value) => isSupportedTarget(value));
|
|
701
|
+
}
|
|
702
|
+
function requireFlagValue(argv, index, flag) {
|
|
703
|
+
const value = argv[index + 1];
|
|
704
|
+
if (!value || value.startsWith("--")) {
|
|
705
|
+
console.error(`Error: ${flag} requires a value.`);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
return value;
|
|
709
|
+
}
|
|
710
|
+
function parseInitArgs(argv) {
|
|
711
|
+
const options = { defaults: argv.includes("--defaults"), quiet: argv.includes("--quiet") };
|
|
712
|
+
for (let index = 0; index < argv.length; index++) {
|
|
713
|
+
const arg = argv[index];
|
|
714
|
+
switch (arg) {
|
|
715
|
+
case "--defaults":
|
|
716
|
+
case "--quiet":
|
|
717
|
+
break;
|
|
718
|
+
case "--name":
|
|
719
|
+
options.name = requireFlagValue(argv, index, arg);
|
|
720
|
+
index++;
|
|
721
|
+
break;
|
|
722
|
+
case "--spec-dir":
|
|
723
|
+
options.specDir = requireFlagValue(argv, index, arg);
|
|
724
|
+
index++;
|
|
725
|
+
break;
|
|
726
|
+
case "--targets":
|
|
727
|
+
options.targets = parseTargetsValue(requireFlagValue(argv, index, arg));
|
|
728
|
+
index++;
|
|
729
|
+
break;
|
|
730
|
+
case "--backend":
|
|
731
|
+
options.backendPath = requireFlagValue(argv, index, arg);
|
|
732
|
+
index++;
|
|
733
|
+
break;
|
|
734
|
+
case "--with-api":
|
|
735
|
+
options.withApi = true;
|
|
736
|
+
break;
|
|
737
|
+
case "--no-api":
|
|
738
|
+
options.withApi = false;
|
|
739
|
+
break;
|
|
740
|
+
case "--configure-targets":
|
|
741
|
+
options.configureTargets = true;
|
|
742
|
+
break;
|
|
743
|
+
case "--no-configure-targets":
|
|
744
|
+
options.configureTargets = false;
|
|
745
|
+
break;
|
|
746
|
+
case "--with-shared":
|
|
747
|
+
options.withShared = true;
|
|
748
|
+
break;
|
|
749
|
+
case "--no-shared":
|
|
750
|
+
options.withShared = false;
|
|
751
|
+
break;
|
|
752
|
+
default:
|
|
753
|
+
if (arg.startsWith("--")) {
|
|
754
|
+
console.error(`Error: Unknown init option: ${arg}`);
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return options;
|
|
760
|
+
}
|
|
761
|
+
function collectDefaults() {
|
|
762
|
+
const cwd = process.cwd();
|
|
763
|
+
const defaultName = cwd.split("/").pop() || "MyApp";
|
|
764
|
+
return {
|
|
765
|
+
name: defaultName,
|
|
766
|
+
specDir: "openuispec",
|
|
767
|
+
targets: [...SUPPORTED_TARGETS],
|
|
768
|
+
withApi: true,
|
|
769
|
+
backendPath: "../backend/",
|
|
770
|
+
configureTargets: true,
|
|
771
|
+
sharedLayers: [],
|
|
772
|
+
structures: [],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const SHARED_LAYER_DEFAULTS = {
|
|
776
|
+
kmp: {
|
|
777
|
+
language: "kotlin",
|
|
778
|
+
root: "../shared",
|
|
779
|
+
tracks: [],
|
|
780
|
+
scope: "Business logic, data models, repositories, API clients, view models/stores. No UI rendering.",
|
|
781
|
+
paths: { domain: "commonMain/domain/", features: "commonMain/features/" },
|
|
782
|
+
structureScope: {
|
|
783
|
+
ios: "Pure SwiftUI views and navigation. All business logic comes from the shared layer.",
|
|
784
|
+
android: "Pure Compose UI and navigation. All business logic comes from the shared layer.",
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
function defaultSharedConfig(targets) {
|
|
789
|
+
const mobilePlatforms = targets.filter((t) => t === "ios" || t === "android");
|
|
790
|
+
if (mobilePlatforms.length < 2) {
|
|
791
|
+
return { sharedLayers: [], structures: [] };
|
|
792
|
+
}
|
|
793
|
+
const preset = SHARED_LAYER_DEFAULTS.kmp;
|
|
794
|
+
const sharedLayers = [{
|
|
795
|
+
name: "mobile_common",
|
|
796
|
+
platforms: mobilePlatforms,
|
|
797
|
+
language: preset.language,
|
|
798
|
+
root: preset.root,
|
|
799
|
+
tracks: preset.tracks,
|
|
800
|
+
scope: preset.scope,
|
|
801
|
+
paths: preset.paths,
|
|
802
|
+
}];
|
|
803
|
+
const structures = mobilePlatforms.map((t) => ({
|
|
804
|
+
target: t,
|
|
805
|
+
root: preset.root,
|
|
806
|
+
scope: preset.structureScope[t] ?? `Pure ${t} UI. Business logic comes from the shared layer.`,
|
|
807
|
+
paths: { ui: `${t}App/ui/` },
|
|
808
|
+
}));
|
|
809
|
+
return { sharedLayers, structures };
|
|
810
|
+
}
|
|
811
|
+
async function collectSharedLayerAnswers(rl, targets) {
|
|
812
|
+
const withShared = await askYesNo(rl, "\nShare code between platforms (e.g. KMP commonMain)?", false);
|
|
813
|
+
if (!withShared)
|
|
814
|
+
return { sharedLayers: [], structures: [] };
|
|
815
|
+
const defaults = defaultSharedConfig(targets);
|
|
816
|
+
const defaultLayer = defaults.sharedLayers[0];
|
|
817
|
+
if (!defaultLayer)
|
|
818
|
+
return { sharedLayers: [], structures: [] };
|
|
819
|
+
console.log("\n Shared layer defaults (KMP):");
|
|
820
|
+
console.log(` platforms: ${defaultLayer.platforms.join(", ")}`);
|
|
821
|
+
console.log(` language: ${defaultLayer.language}`);
|
|
822
|
+
console.log(` root: ${defaultLayer.root}`);
|
|
823
|
+
console.log(` scope: ${defaultLayer.scope}`);
|
|
824
|
+
const useDefaults = await askYesNo(rl, " Use these defaults?", true);
|
|
825
|
+
if (useDefaults)
|
|
826
|
+
return defaults;
|
|
827
|
+
const layerName = await ask(rl, " Shared layer name", defaultLayer.name);
|
|
828
|
+
const platformsRaw = await askList(rl, " Platforms", targets, defaultLayer.platforms);
|
|
829
|
+
const language = await ask(rl, " Language", defaultLayer.language);
|
|
830
|
+
const root = await ask(rl, " Root path (relative to openuispec.yaml)", defaultLayer.root);
|
|
831
|
+
const scope = await ask(rl, " Scope (what code belongs here)", defaultLayer.scope);
|
|
832
|
+
const wantTracks = await askYesNo(rl, " Enable hash-based drift tracking for this layer?", false);
|
|
833
|
+
const tracksRaw = wantTracks
|
|
834
|
+
? await askList(rl, " Tracked spec categories", ["manifest", "tokens", "contracts", "screens", "flows", "platform", "locales"], ["manifest", "contracts", "flows"])
|
|
835
|
+
: [];
|
|
836
|
+
const sharedLayers = [{
|
|
837
|
+
name: layerName,
|
|
838
|
+
platforms: platformsRaw,
|
|
839
|
+
language,
|
|
840
|
+
root,
|
|
841
|
+
tracks: tracksRaw,
|
|
842
|
+
scope,
|
|
843
|
+
paths: defaultLayer.paths,
|
|
844
|
+
}];
|
|
845
|
+
const structures = [];
|
|
846
|
+
for (const t of platformsRaw) {
|
|
847
|
+
const defaultStructure = defaults.structures.find((s) => s.target === t);
|
|
848
|
+
const structRoot = await ask(rl, ` ${t} structure root`, defaultStructure?.root ?? root);
|
|
849
|
+
const structScope = await ask(rl, ` ${t} scope (what code belongs in the ${t} target)`, defaultStructure?.scope ?? `Pure ${t} UI rendering.`);
|
|
850
|
+
structures.push({
|
|
851
|
+
target: t,
|
|
852
|
+
root: structRoot,
|
|
853
|
+
scope: structScope,
|
|
854
|
+
paths: defaultStructure?.paths ?? { ui: `${t}App/ui/` },
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
return { sharedLayers, structures };
|
|
858
|
+
}
|
|
859
|
+
async function collectInteractiveAnswers(rl) {
|
|
860
|
+
const defaults = collectDefaults();
|
|
861
|
+
const name = await ask(rl, "Project name", defaults.name);
|
|
862
|
+
const specDir = await ask(rl, "Spec directory", defaults.specDir);
|
|
863
|
+
const targets = await askList(rl, "\nWhich platforms?", [...SUPPORTED_TARGETS], defaults.targets);
|
|
864
|
+
if (targets.length === 0) {
|
|
865
|
+
console.error("At least one target is required.");
|
|
866
|
+
process.exit(1);
|
|
867
|
+
}
|
|
868
|
+
const withApi = await askYesNo(rl, "Will this spec declare API endpoints?", defaults.withApi);
|
|
869
|
+
const backendPath = withApi
|
|
870
|
+
? await ask(rl, "Backend folder path relative to openuispec.yaml", defaults.backendPath ?? "../backend/")
|
|
871
|
+
: null;
|
|
872
|
+
const configureTargets = await askYesNo(rl, "Configure target stacks now?", defaults.configureTargets);
|
|
873
|
+
const { sharedLayers, structures } = await collectSharedLayerAnswers(rl, targets);
|
|
874
|
+
return {
|
|
875
|
+
name,
|
|
876
|
+
specDir,
|
|
877
|
+
targets,
|
|
878
|
+
withApi,
|
|
879
|
+
backendPath,
|
|
880
|
+
configureTargets,
|
|
881
|
+
sharedLayers,
|
|
882
|
+
structures,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function collectNonInteractiveAnswers(argv) {
|
|
886
|
+
const parsed = parseInitArgs(argv);
|
|
887
|
+
const defaults = collectDefaults();
|
|
888
|
+
if (!parsed.defaults && argv.filter((a) => a !== "--quiet").length === 0) {
|
|
889
|
+
console.error("Error: `openuispec init` needs a TTY for prompts.\n" +
|
|
890
|
+
"Run with `--list-options` to get prompt definitions as JSON, or pass flags such as `--name`, `--targets`, `--with-api`, `--backend`, and `--configure-targets`.");
|
|
891
|
+
process.exit(1);
|
|
892
|
+
}
|
|
893
|
+
const targets = parsed.targets && parsed.targets.length > 0 ? parsed.targets : defaults.targets;
|
|
894
|
+
if (targets.length === 0) {
|
|
895
|
+
console.error("Error: --targets must include at least one of ios, android, web.");
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
const withApi = parsed.withApi ?? defaults.withApi;
|
|
899
|
+
const backendPath = withApi ? parsed.backendPath ?? defaults.backendPath : null;
|
|
900
|
+
const withShared = parsed.withShared ?? false;
|
|
901
|
+
const { sharedLayers, structures } = withShared ? defaultSharedConfig(targets) : { sharedLayers: [], structures: [] };
|
|
902
|
+
return {
|
|
903
|
+
name: parsed.name ?? defaults.name,
|
|
904
|
+
specDir: parsed.specDir ?? defaults.specDir,
|
|
905
|
+
targets,
|
|
906
|
+
withApi,
|
|
907
|
+
backendPath,
|
|
908
|
+
configureTargets: parsed.configureTargets ?? defaults.configureTargets,
|
|
909
|
+
sharedLayers,
|
|
910
|
+
structures,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
// ── main ─────────────────────────────────────────────────────────────
|
|
914
|
+
export async function init(argv = []) {
|
|
915
|
+
if (argv.includes("--list-options")) {
|
|
916
|
+
console.log(JSON.stringify(listInitOptions(), null, 2));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const quiet = argv.includes("--quiet");
|
|
920
|
+
const interactive = stdin.isTTY && stdout.isTTY && !argv.includes("--defaults");
|
|
921
|
+
const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
|
|
922
|
+
if (!quiet)
|
|
923
|
+
console.log("\nOpenUISpec — Project Setup\n");
|
|
924
|
+
try {
|
|
925
|
+
const cwd = process.cwd();
|
|
926
|
+
const answers = rl ? await collectInteractiveAnswers(rl) : collectNonInteractiveAnswers(argv);
|
|
927
|
+
rl?.close();
|
|
928
|
+
// ── create folders ─────────────────────────────────────────────
|
|
929
|
+
if (!quiet)
|
|
930
|
+
console.log("\nScaffolding...\n");
|
|
931
|
+
const root = join(cwd, answers.specDir);
|
|
932
|
+
const dirs = [
|
|
933
|
+
"tokens",
|
|
934
|
+
"contracts",
|
|
935
|
+
"components",
|
|
936
|
+
"screens",
|
|
937
|
+
"flows",
|
|
938
|
+
"platform",
|
|
939
|
+
"locales",
|
|
940
|
+
];
|
|
941
|
+
ensureDir(root);
|
|
942
|
+
for (const d of dirs) {
|
|
943
|
+
ensureDir(join(root, d));
|
|
944
|
+
}
|
|
945
|
+
// ── manifest ───────────────────────────────────────────────────
|
|
946
|
+
writeIfMissing(join(root, "openuispec.yaml"), manifestTemplate(answers.name, answers.targets, {
|
|
947
|
+
withApi: answers.withApi,
|
|
948
|
+
backendPath: answers.backendPath,
|
|
949
|
+
sharedLayers: answers.sharedLayers,
|
|
950
|
+
structures: answers.structures,
|
|
951
|
+
}), quiet);
|
|
952
|
+
// ── spec README ──────────────────────────────────────────────
|
|
953
|
+
writeIfMissing(join(root, "README.md"), specReadmeTemplate(answers.name, answers.targets), quiet);
|
|
954
|
+
// ── .gitkeep for empty dirs ────────────────────────────────────
|
|
955
|
+
for (const d of dirs) {
|
|
956
|
+
const dir = join(root, d);
|
|
957
|
+
const entries = existsSync(dir)
|
|
958
|
+
? readdirSync(dir).filter((f) => f !== ".gitkeep")
|
|
959
|
+
: [];
|
|
960
|
+
if (entries.length === 0) {
|
|
961
|
+
const gk = join(dir, ".gitkeep");
|
|
962
|
+
if (!existsSync(gk)) {
|
|
963
|
+
writeFileSync(gk, "");
|
|
964
|
+
if (!quiet)
|
|
965
|
+
console.log(` create ${relative(cwd, gk)}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// ── AI assistant rules ─────────────────────────────────────────
|
|
970
|
+
const rules = aiRulesBlock(answers.specDir, answers.targets);
|
|
971
|
+
for (const file of ["CLAUDE.md", "AGENTS.md"]) {
|
|
972
|
+
const filePath = join(cwd, file);
|
|
973
|
+
if (existsSync(filePath)) {
|
|
974
|
+
const existing = readFileSync(filePath, "utf-8");
|
|
975
|
+
if (existing.includes("OpenUISpec")) {
|
|
976
|
+
if (!quiet)
|
|
977
|
+
console.log(` skip ${file} (already has OpenUISpec rules)`);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
appendFileSync(filePath, "\n" + rules);
|
|
981
|
+
if (!quiet)
|
|
982
|
+
console.log(` update ${file} (appended rules)`);
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
writeFileSync(filePath, rules.trimStart());
|
|
986
|
+
if (!quiet)
|
|
987
|
+
console.log(` create ${file}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// ── MCP server configuration ────────────────────────────────────
|
|
991
|
+
configureMcp(cwd, false, quiet);
|
|
992
|
+
if (answers.configureTargets) {
|
|
993
|
+
if (!quiet)
|
|
994
|
+
console.log("\nConfiguring target stacks...\n");
|
|
995
|
+
const { runConfigureTarget } = await import("./configure-target.js");
|
|
996
|
+
for (const target of answers.targets) {
|
|
997
|
+
await runConfigureTarget([target, ...(interactive ? [] : ["--defaults"]), ...(quiet ? ["--silent"] : [])]);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// ── done ───────────────────────────────────────────────────────
|
|
1001
|
+
if (quiet) {
|
|
1002
|
+
console.log(`./${answers.specDir}/`);
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
console.log(`
|
|
1006
|
+
Done! Your spec project is ready at ./${answers.specDir}/
|
|
1007
|
+
|
|
1008
|
+
Getting started (new project):
|
|
1009
|
+
1. Edit ${answers.specDir}/openuispec.yaml — define your data model and API
|
|
1010
|
+
2. Create tokens in ${answers.specDir}/tokens/ (colors, typography, spacing, etc.)
|
|
1011
|
+
3. Create contract extensions in ${answers.specDir}/contracts/ (visual variants for the 7 built-in contracts)
|
|
1012
|
+
4. Create screens in ${answers.specDir}/screens/ (one YAML per screen)
|
|
1013
|
+
5. Create flows in ${answers.specDir}/flows/ (multi-step navigation)
|
|
1014
|
+
6. Create locale files in ${answers.specDir}/locales/ (one JSON per supported locale)
|
|
1015
|
+
7. Run \`openuispec validate\` and \`openuispec validate semantic\` to check everything
|
|
1016
|
+
8. Ask AI to generate native code from the spec
|
|
1017
|
+
9. Run \`openuispec drift --snapshot --target ${answers.targets[0]}\` to baseline the first accepted target state after that target output directory exists
|
|
1018
|
+
|
|
1019
|
+
Getting started (existing project):
|
|
1020
|
+
1. Ask AI to read your existing UI code and generate spec files:
|
|
1021
|
+
"Read src/screens/HomeScreen.swift and create ${answers.specDir}/screens/home.yaml as status: stub"
|
|
1022
|
+
2. Spec screens incrementally: stub → draft → ready
|
|
1023
|
+
3. Only ready/draft screens are tracked by drift detection
|
|
1024
|
+
4. Run \`openuispec validate\` to check specs against the schema
|
|
1025
|
+
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
|
|
1026
|
+
|
|
1027
|
+
Commands:
|
|
1028
|
+
openuispec validate Validate spec files
|
|
1029
|
+
openuispec validate semantic Check semantic cross-references
|
|
1030
|
+
openuispec configure-target ios [--defaults] Configure target stack; --defaults stays unconfirmed
|
|
1031
|
+
openuispec status Show cross-target baseline/drift status
|
|
1032
|
+
openuispec drift --target ios --explain Explain semantic spec changes
|
|
1033
|
+
openuispec prepare --target ios Build the target work bundle
|
|
1034
|
+
openuispec drift --snapshot --target ios Save current state + git baseline after target output exists
|
|
1035
|
+
|
|
1036
|
+
AI rules have been added to CLAUDE.md and AGENTS.md.
|
|
1037
|
+
MCP server configured (AI assistants will use openuispec tools automatically).
|
|
1038
|
+
|
|
1039
|
+
Docs: https://openuispec.rsteam.uz
|
|
1040
|
+
`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch (err) {
|
|
1044
|
+
rl?.close();
|
|
1045
|
+
throw err;
|
|
1046
|
+
}
|
|
1047
|
+
}
|