create-cmp-cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/llms.txt +18 -1
- package/package.json +8 -3
- package/src/bootstrap/checks.mjs +9 -2
- package/src/lib/tabs.mjs +113 -0
- package/src/lib/verify.mjs +24 -1
- package/src/scaffold.mjs +60 -2
- package/template/AGENTS.md +8 -0
- package/template/CLAUDE.md +32 -0
- package/template/README.md +5 -0
- package/template/composeApp/build.gradle.kts +66 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/core/format/Format.kt +36 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/core/format/FormatTest.kt +36 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +219 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +265 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +61 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +83 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +83 -12
- package/template/manifest.json +4 -2
- package/template/qa/lib/a11y.mjs +104 -0
- package/template/qa/lib/render.mjs +254 -0
- package/template/qa/lib/tree.mjs +108 -0
- package/template/qa/preview-gallery.mjs +107 -0
- package/template/specs/app-base.spec.md +15 -7
package/README.md
CHANGED
|
@@ -25,7 +25,8 @@ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --veri
|
|
|
25
25
|
Deterministic (stamps a frozen, CI-verified template), fully non-interactive with flags, and
|
|
26
26
|
exits non-zero on failure. Every generated project ships its own verify lane — `node qa/verify.mjs`,
|
|
27
27
|
8 gates, evidence receipts — with nothing installed. Agent-readable: [llms.txt](./llms.txt) ·
|
|
28
|
-
[options.schema.json](./options.schema.json).
|
|
28
|
+
[options.schema.json](./options.schema.json). Also answers to `npm create compose-multiplatform`
|
|
29
|
+
and `npm create kmp` — official aliases ([packages/aliases](packages/aliases)) that delegate here.
|
|
29
30
|
|
|
30
31
|
## What is this, in plain words
|
|
31
32
|
|
|
@@ -100,7 +101,7 @@ Everything except `create` works on **any** KMP project, not just ones this tool
|
|
|
100
101
|
Hit a KMP build error? [Common CMP/KMP build errors and fixes](docs/errors/README.md) — kotlin↔KSP
|
|
101
102
|
mismatch, the KSP2/iOS catch-22, `SDK location not found`, `No space left on device`, version drift.
|
|
102
103
|
|
|
103
|
-
## The Claude Code plugin (
|
|
104
|
+
## The Claude Code plugin (9 skills)
|
|
104
105
|
|
|
105
106
|
```text
|
|
106
107
|
/plugin marketplace add kvdm-co-pilot/create-cmp
|
|
@@ -114,13 +115,14 @@ Same engine as the CLI, conversational front door. Each skill is a guided flow,
|
|
|
114
115
|
| `cmp-new` | "Make me an app." Interviews you in chat, scaffolds via the engine, generates your bottom-nav tab screens from the exemplar pattern, proves the build green. |
|
|
115
116
|
| `cmp-doctor` | "Why won't my KMP project build?" Runs the doctor, explains the findings, applies consented fixes. |
|
|
116
117
|
| `cmp-upgrade` | "Bump my dependencies safely." Diff → apply → verify, with the lockstep guardrails. |
|
|
118
|
+
| `cmp-preview` | "Show me my screens." Live gallery of every screen at a local URL — real DI/theme/data, no device, no emulator, no manual Gradle. Edit → save → the page re-renders itself; changed screens get flagged; a11y violations show per screen. |
|
|
117
119
|
| `cmp-inspect` | "What did the UI actually render?" Reads a **running** app as structured JSON — hierarchy, geometry, resolved design tokens, navigation state. Never screenshots. Can assert tokens, find drift against your design system, audit accessibility, diff before/after. |
|
|
118
120
|
| `cmp-dev-client` | "Let me iterate fast." Runs your shared UI in a phone-sized desktop window with hot reload — save a file, see it change. No emulator needed. Firebase stays off on desktop (offline fakes). |
|
|
119
121
|
| `cmp-firebase-connect` | "Wire up my real Firebase." Drives the Firebase CLI: create/reuse a project, register the app, drop the real `google-services.json` over the placeholder, prove it with a green build. Every cloud action asks first. |
|
|
120
122
|
| `cmp-test` | "Write tests for my app." *Observes* the running app's semantics tree — what's actually on screen, what's tappable, where navigation goes — and derives the regression suite from that. Tests come from rendered reality, not guesses. |
|
|
121
123
|
| `cmp-qa-prep` | "Get my test environment up." Emulator + app install + E2E smoke run, with the gotchas handled. |
|
|
122
124
|
|
|
123
|
-
Plus the **`cmp-inspector` MCP server** (
|
|
125
|
+
Plus the **`cmp-inspector` MCP server** (18 tools) — the machine-readable window into a running
|
|
124
126
|
Compose UI that `cmp-inspect`, `cmp-test`, and the verified dev loop are built on. One tree
|
|
125
127
|
contract, three sources: render a screen headlessly, connect to the live app, or read a device
|
|
126
128
|
via UIAutomator.
|
|
@@ -181,13 +183,21 @@ stamper (`qa/scaffold-feature.mjs`):
|
|
|
181
183
|
Any plain Claude Code session — no create-cmp plugin installed — finds these and extends the app
|
|
182
184
|
correctly by construction.
|
|
183
185
|
|
|
184
|
-
### 6. The
|
|
185
|
-
|
|
186
|
-
UI tree as JSON, the design-token catalog,
|
|
187
|
-
view for humans (`/inspect/remote` — watch the
|
|
188
|
-
|
|
186
|
+
### 6. The inspector — AI-readable UI, previews without a device
|
|
187
|
+
Two loops, one contract. **Live (tier 1):** every debug build serves `127.0.0.1:9500`
|
|
188
|
+
(loopback-only, structurally absent from release): the UI tree as JSON, the design-token catalog,
|
|
189
|
+
a screenshot route, a tap route, and a live device view for humans (`/inspect/remote` — watch the
|
|
190
|
+
real device in a browser, click to tap). **Headless previews (tier 0):** every app ships
|
|
191
|
+
`inspector/PreviewRegistry.kt` (the `@Preview` analog — shell, every tab, detail) and a
|
|
192
|
+
`:composeApp:renderScreens` task that renders each screen with real DI/theme/data to
|
|
193
|
+
`screen.png` + its contract `tree.json` — no device, no emulator; `node qa/preview-gallery.mjs`
|
|
194
|
+
turns the output into one self-contained `index.html` (pixels + wireframe + a11y per screen).
|
|
195
|
+
Agents read structure; humans see pixels.
|
|
189
196
|
|
|
190
197
|
### 7. The daily-driver extras
|
|
198
|
+
- **Live preview loop** — every real screen rendered headlessly on save (resident hot-reload
|
|
199
|
+
daemon, ~1s warm renders); a self-updating gallery for the human, changed-screen attribution
|
|
200
|
+
and compile-error surfacing for the agent. The agent sees what it builds.
|
|
191
201
|
- **Desktop dev-client** — shared UI in a phone-sized JVM window, Compose Hot Reload attached.
|
|
192
202
|
- **CI workflow** — Android job on every push; iOS job ready to un-comment.
|
|
193
203
|
- **`CLAUDE.md`** — the AI delivery contract itself, stating everything above as rules any AI
|
|
@@ -200,8 +210,15 @@ read structure; humans see pixels.
|
|
|
200
210
|
**New app → green.** `cmp-new` (or `npx create-cmp-cli`) → interview → stamp → green build proven
|
|
201
211
|
→ tab screens generated. Then `cmp-firebase-connect` to wire your real backend.
|
|
202
212
|
|
|
203
|
-
**The daily UI loop.**
|
|
204
|
-
|
|
213
|
+
**The daily UI loop.** Say "preview my app" (the cmp-preview skill / `preview` MCP tool) → a
|
|
214
|
+
live local gallery of EVERY real screen that re-renders on save — no device, no emulator, no
|
|
215
|
+
manual Gradle. The agent runs the same loop to check its own work while it builds: edit →
|
|
216
|
+
`preview_status { waitForRender: true }` → which screens changed (or the compile error, or the
|
|
217
|
+
failed hot swap) → `preview_diff { screen }` for a proven verdict — feedback in seconds instead
|
|
218
|
+
of a 25–40s build or an emulator round-trip. One interactive window instead of stills:
|
|
219
|
+
`./gradlew :composeApp:hotRunDesktop --auto`. Command-line fallback (no plugin needed — the
|
|
220
|
+
scaffolded app carries the whole loop):
|
|
221
|
+
`./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`.
|
|
205
222
|
|
|
206
223
|
**The verified dev loop (the flagship).** For any UI change: snapshot the live tree → make the
|
|
207
224
|
edit → reload → `prove_change` compares before/after structure, token drift, and a11y, and returns
|
package/llms.txt
CHANGED
|
@@ -12,7 +12,24 @@ npx create-cmp-cli@latest my-app --name Acme --package com.acme.app --yes --veri
|
|
|
12
12
|
|
|
13
13
|
Other flags: `--bundle-id`, `--region`, `--theme-prefix`, `--ios/--no-ios`, `--firebase/--no-firebase`, `--auth <email|phone|both|none>`, `--room/--no-room`, `--e2e/--no-e2e`, `--inspector/--no-inspector`, `--dev-client/--no-dev-client`, `--tabs Home:home,Profile:person`, `--target-dir`, `--force`. Subcommands `doctor`, `upgrade`, `clean`, and `verify` work on any KMP project.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Official alias packages (same tool, same flags, same maintainer): `npm create compose-multiplatform@latest my-app` and `npm create kmp@latest my-app` both delegate to create-cmp-cli.
|
|
16
|
+
|
|
17
|
+
create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with nine skills and the `cmp-inspector` MCP server (18 tools). Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
|
|
18
|
+
|
|
19
|
+
## The UI feedback loop (for agents building UI)
|
|
20
|
+
|
|
21
|
+
Every generated app carries a device-free preview loop — no emulator, no manual Gradle. An
|
|
22
|
+
agent editing ANY screen should use it to see and verify its own work: with the plugin,
|
|
23
|
+
`preview { projectDir }` starts a resident service (live self-updating gallery URL for the
|
|
24
|
+
human), then after each edit `preview_status { waitForRender: true }` blocks until the
|
|
25
|
+
outcome — `changedLastRender` names the screens the edit touched (empty = it reached no
|
|
26
|
+
screen), `lastErrorSource: "compile"` means it didn't build — and `preview_diff { screen }`
|
|
27
|
+
returns a proven verdict (`proven-clean` / `changed-with-regressions` / `no-change`).
|
|
28
|
+
Warm renders take ~1s per screen (resident hot-reload daemon). Without the plugin:
|
|
29
|
+
`./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`. Screens are registered
|
|
30
|
+
in `inspector/PreviewRegistry.kt`; the generated `CLAUDE.md`/`AGENTS.md` document the loop
|
|
31
|
+
in-repo ("UI feedback loop" section). Pixels flow to the human, structure (tree JSON) flows
|
|
32
|
+
to the agent — never read PNG bytes into model context.
|
|
16
33
|
|
|
17
34
|
## Docs
|
|
18
35
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Installs the `create-cmp` command.",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-cmp": "bin/create-cmp.mjs",
|
|
@@ -41,7 +41,12 @@
|
|
|
41
41
|
"android",
|
|
42
42
|
"ios",
|
|
43
43
|
"verify",
|
|
44
|
-
"e2e"
|
|
44
|
+
"e2e",
|
|
45
|
+
"preview",
|
|
46
|
+
"storybook",
|
|
47
|
+
"agent feedback loop",
|
|
48
|
+
"agents.md",
|
|
49
|
+
"claude"
|
|
45
50
|
],
|
|
46
51
|
"license": "MIT",
|
|
47
52
|
"author": "Karel van der Merwe",
|
package/src/bootstrap/checks.mjs
CHANGED
|
@@ -71,7 +71,11 @@ export const checks = [
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
id: "jdk",
|
|
74
|
-
|
|
74
|
+
// Label states the actual requirement (17+), and detect() reports the
|
|
75
|
+
// resolved major — previously the row read "JDK 17 (Temurin)" while
|
|
76
|
+
// happily accepting JDK 21, a label/evidence contradiction that erodes
|
|
77
|
+
// trust in every other row (field-report finding 2.6).
|
|
78
|
+
label: "JDK (17+ required)",
|
|
75
79
|
platforms: ["darwin", "linux"],
|
|
76
80
|
detect() {
|
|
77
81
|
const r = probe("javac", ["-version"]);
|
|
@@ -80,7 +84,10 @@ export const checks = [
|
|
|
80
84
|
const m = out.match(/(\d+)(\.\d+)?/);
|
|
81
85
|
const major = m ? parseInt(m[1], 10) : 0;
|
|
82
86
|
if (!out) return { present: false, detail: "not found" };
|
|
83
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
present: major >= 17,
|
|
89
|
+
detail: `resolved major ${major} — ${out.split("\n")[0]}`,
|
|
90
|
+
};
|
|
84
91
|
},
|
|
85
92
|
installCommand: () =>
|
|
86
93
|
isMac ? "brew install --cask temurin@17" : "sdk install java 17.0.13-tem # (sdkman)",
|
package/src/lib/tabs.mjs
CHANGED
|
@@ -317,6 +317,109 @@ fun PlaceholderScreen(title: String, titleTag: String) {
|
|
|
317
317
|
`;
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/** Preview content lambda for one tab in the registry's appTabs(...) call — no-op navigation. */
|
|
321
|
+
function previewTabArg(tab) {
|
|
322
|
+
if (tab.slug === "home") {
|
|
323
|
+
return ` ${tab.param} = { HomeScreen(onItemClick = {}) },`;
|
|
324
|
+
}
|
|
325
|
+
if (tab.slug === "profile") {
|
|
326
|
+
return ` ${tab.param} = { ProfileScreen() },`;
|
|
327
|
+
}
|
|
328
|
+
return ` ${tab.param} = { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") },`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Per-tab ScreenPreview entry (single tab hosted in TabHost, as AppShell hosts it). */
|
|
332
|
+
function previewEntry(tab) {
|
|
333
|
+
if (tab.slug === "home") {
|
|
334
|
+
return ` ScreenPreview("home", "${kotlinString(tab.label)} tab") { TabHost { HomeScreen(onItemClick = {}) } },`;
|
|
335
|
+
}
|
|
336
|
+
if (tab.slug === "profile") {
|
|
337
|
+
return ` ScreenPreview("profile", "${kotlinString(tab.label)} tab") { TabHost { ProfileScreen() } },`;
|
|
338
|
+
}
|
|
339
|
+
return ` ScreenPreview("${tab.slug}", "${kotlinString(tab.label)} tab") { TabHost { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") } },`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Render inspector/PreviewRegistry.kt (template-token form) — the `@Preview` analog the
|
|
344
|
+
* renderScreens harness enumerates: one shell entry wiring appTabs(...) like AppNavHost,
|
|
345
|
+
* one entry per tab, plus the shipped Detail destination. For the default tabs this
|
|
346
|
+
* reproduces the static template file byte-for-byte.
|
|
347
|
+
* @param {ReturnType<typeof tabInfos>} infos
|
|
348
|
+
*/
|
|
349
|
+
export function renderPreviewRegistryKt(infos) {
|
|
350
|
+
const imports = [
|
|
351
|
+
"import androidx.compose.foundation.layout.Box",
|
|
352
|
+
"import androidx.compose.foundation.layout.fillMaxSize",
|
|
353
|
+
"import androidx.compose.runtime.Composable",
|
|
354
|
+
"import androidx.compose.ui.Modifier",
|
|
355
|
+
"import __PACKAGE__.presentation.components.BaseScreen",
|
|
356
|
+
];
|
|
357
|
+
if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
|
|
358
|
+
imports.push("import __PACKAGE__.presentation.components.PlaceholderScreen");
|
|
359
|
+
}
|
|
360
|
+
imports.push("import __PACKAGE__.presentation.home.DetailScreen");
|
|
361
|
+
if (infos.some((t) => t.slug === "home")) {
|
|
362
|
+
imports.push("import __PACKAGE__.presentation.home.HomeScreen");
|
|
363
|
+
}
|
|
364
|
+
imports.push("import __PACKAGE__.presentation.navigation.AppShell");
|
|
365
|
+
imports.push("import __PACKAGE__.presentation.navigation.appTabs");
|
|
366
|
+
if (infos.some((t) => t.slug === "profile")) {
|
|
367
|
+
imports.push("import __PACKAGE__.presentation.profile.ProfileScreen");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return `package __PACKAGE__.inspector
|
|
371
|
+
|
|
372
|
+
${imports.join("\n")}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* One previewable screen: a stable [id] (the \`-Pscreen=\` selector and output directory
|
|
376
|
+
* name), a human [title] for the gallery, and the composable [content] exactly as the
|
|
377
|
+
* app hosts it.
|
|
378
|
+
*
|
|
379
|
+
* The \`@Preview\` analog for the create-cmp inspector: the registry makes "render screen
|
|
380
|
+
* X" a closed, enumerable operation. The scaffolder regenerates the tab entries from the
|
|
381
|
+
* configured \`tabs\`; when you add a screen by hand, add it here — the renderScreens
|
|
382
|
+
* harness, the gallery, and golden baselines pick it up by id.
|
|
383
|
+
*
|
|
384
|
+
* State variants (the Storybook "story" analog): a screen in a specific UI state is just
|
|
385
|
+
* another entry with a derived id — e.g. \`ScreenPreview("home@empty", "Home — empty")\`
|
|
386
|
+
* hosting the screen with that state forced (a state-first overload of the screen, or
|
|
387
|
+
* preview-only fakes behind its usual parameters). Every entry renders the same way
|
|
388
|
+
* (gallery card, \`-Pscreen=\` selector, golden baseline), so loading/empty/error states
|
|
389
|
+
* sit side by side with the default seeded state.
|
|
390
|
+
*/
|
|
391
|
+
data class ScreenPreview(
|
|
392
|
+
val id: String,
|
|
393
|
+
val title: String,
|
|
394
|
+
val content: @Composable () -> Unit,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
/** Every registered screen, in gallery order. Ids must be unique and filesystem-safe. */
|
|
398
|
+
fun previewRegistry(): List<ScreenPreview> = listOf(
|
|
399
|
+
ScreenPreview("shell", "App shell — bottom nav (first tab selected)") {
|
|
400
|
+
AppShell(
|
|
401
|
+
tabs = appTabs(
|
|
402
|
+
${infos.map(previewTabArg).join("\n")}
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
},
|
|
406
|
+
${infos.map(previewEntry).join("\n")}
|
|
407
|
+
ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
|
|
412
|
+
* bottom bar, so a tab previews with the same insets/background it gets in the shell.
|
|
413
|
+
*/
|
|
414
|
+
@Composable
|
|
415
|
+
private fun TabHost(content: @Composable () -> Unit) {
|
|
416
|
+
BaseScreen {
|
|
417
|
+
Box(Modifier.fillMaxSize()) { content() }
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
`;
|
|
421
|
+
}
|
|
422
|
+
|
|
320
423
|
// --- pipeline entry --------------------------------------------------------------
|
|
321
424
|
|
|
322
425
|
const APPTAB_REL =
|
|
@@ -325,6 +428,8 @@ const NAVHOST_REL =
|
|
|
325
428
|
"composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt";
|
|
326
429
|
const PLACEHOLDER_REL =
|
|
327
430
|
"composeApp/src/commonMain/kotlin/com/example/app/presentation/components/PlaceholderScreen.kt";
|
|
431
|
+
const PREVIEW_REGISTRY_REL =
|
|
432
|
+
"composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt";
|
|
328
433
|
const SMOKE_REL = "qa/e2e/smoke.yaml";
|
|
329
434
|
|
|
330
435
|
/**
|
|
@@ -362,6 +467,14 @@ export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
|
|
|
362
467
|
}
|
|
363
468
|
}
|
|
364
469
|
|
|
470
|
+
// Present only when the inspector feature is on (the harness's desktopMain dir);
|
|
471
|
+
// absent → skipped, same contract as the other feature-stripped surfaces.
|
|
472
|
+
const previewRegistryPath = path.join(projectDir, PREVIEW_REGISTRY_REL);
|
|
473
|
+
if (fs.existsSync(previewRegistryPath)) {
|
|
474
|
+
fs.writeFileSync(previewRegistryPath, renderPreviewRegistryKt(infos));
|
|
475
|
+
log(` tabs → ${PREVIEW_REGISTRY_REL}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
365
478
|
const smokePath = path.join(projectDir, SMOKE_REL);
|
|
366
479
|
if (fs.existsSync(smokePath)) {
|
|
367
480
|
fs.writeFileSync(smokePath, renderSmokeYaml(infos));
|
package/src/lib/verify.mjs
CHANGED
|
@@ -66,8 +66,15 @@ export async function runVerify({ projectDir, manifest, config, dryRun = false }
|
|
|
66
66
|
results.push({ platform: item.platform, command: item.command, code: 0, ran: false });
|
|
67
67
|
continue;
|
|
68
68
|
}
|
|
69
|
+
const startedAt = Date.now();
|
|
69
70
|
const { code } = await runCommand(item.command, projectDir);
|
|
70
|
-
results.push({
|
|
71
|
+
results.push({
|
|
72
|
+
platform: item.platform,
|
|
73
|
+
command: item.command,
|
|
74
|
+
code,
|
|
75
|
+
ran: true,
|
|
76
|
+
durationMs: Date.now() - startedAt,
|
|
77
|
+
});
|
|
71
78
|
if (code !== 0) green = false;
|
|
72
79
|
}
|
|
73
80
|
|
|
@@ -94,4 +101,20 @@ export function printVerifyVerdict(verdict) {
|
|
|
94
101
|
? `\n${colors.green("GREEN — build proven.")}\n`
|
|
95
102
|
: `\n${colors.red("FAIL — build did not go green.")}\n`
|
|
96
103
|
);
|
|
104
|
+
|
|
105
|
+
// Machine-readable verdict, one greppable line (field-report finding 2.3):
|
|
106
|
+
// a verify run can exceed 170k log lines, where "-Werror=" clang flags and
|
|
107
|
+
// Xcode phase names false-positive naive error greps. Agents anchor on this
|
|
108
|
+
// marker instead of parsing raw Gradle/xcodebuild output.
|
|
109
|
+
process.stdout.write(
|
|
110
|
+
`::create-cmp-verdict::${JSON.stringify({
|
|
111
|
+
green: verdict.green,
|
|
112
|
+
results: verdict.results.map((r) => ({
|
|
113
|
+
platform: r.platform,
|
|
114
|
+
green: r.code === 0,
|
|
115
|
+
ran: r.ran,
|
|
116
|
+
durationMs: r.durationMs ?? null,
|
|
117
|
+
})),
|
|
118
|
+
})}\n`
|
|
119
|
+
);
|
|
97
120
|
}
|
package/src/scaffold.mjs
CHANGED
|
@@ -205,6 +205,46 @@ function applyAppNameSlug(projectDir, appName) {
|
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Persist the fully-resolved engine config as `create-cmp.json` in the project
|
|
210
|
+
* root — the durable spec-of-record (field-report finding 5.5-1 / D6). The
|
|
211
|
+
* conformance/consistency tooling compares code against whatever this file
|
|
212
|
+
* currently says, so a hand-edit is a visible spec change rather than drift.
|
|
213
|
+
* @param {string} projectDir
|
|
214
|
+
* @param {object} config validated engine config
|
|
215
|
+
*/
|
|
216
|
+
function writeSpecOfRecord(projectDir, config) {
|
|
217
|
+
let engineVersion = "unknown";
|
|
218
|
+
try {
|
|
219
|
+
engineVersion = JSON.parse(
|
|
220
|
+
fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")
|
|
221
|
+
).version;
|
|
222
|
+
} catch {
|
|
223
|
+
/* best-effort — never fail the stamp over version metadata */
|
|
224
|
+
}
|
|
225
|
+
const record = {
|
|
226
|
+
schemaVersion: 1,
|
|
227
|
+
name: config.appName,
|
|
228
|
+
package: config.package,
|
|
229
|
+
bundleId: config.iosBundleId,
|
|
230
|
+
themePrefix: config.themePrefix,
|
|
231
|
+
region: config.region,
|
|
232
|
+
platforms: config.platforms,
|
|
233
|
+
firebase: config.firebase,
|
|
234
|
+
room: config.room,
|
|
235
|
+
e2e: config.e2e,
|
|
236
|
+
inspector: config.inspector,
|
|
237
|
+
devClient: config.devClient,
|
|
238
|
+
tabs: config.tabs,
|
|
239
|
+
engineVersion,
|
|
240
|
+
stampedAt: new Date().toISOString(),
|
|
241
|
+
};
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
path.join(projectDir, "create-cmp.json"),
|
|
244
|
+
JSON.stringify(record, null, 2) + "\n"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
208
248
|
/**
|
|
209
249
|
* Run the full scaffold pipeline.
|
|
210
250
|
* @param {object} config engine config object (CONTRACT)
|
|
@@ -231,10 +271,20 @@ export async function scaffold(config, opts = {}) {
|
|
|
231
271
|
|
|
232
272
|
const projectDir = path.resolve(config.targetDir);
|
|
233
273
|
if (fs.existsSync(projectDir)) {
|
|
274
|
+
// Harmless entries must not force `--force`: our own doctor/session
|
|
275
|
+
// droppings (.claude), VCS metadata, and OS/editor noise. The documented
|
|
276
|
+
// doctor→create flow used to poison its own target dir this way. Anything
|
|
277
|
+
// else is real user content and still refuses — naming the offenders so
|
|
278
|
+
// the caller can decide without an `ls` round-trip.
|
|
279
|
+
const HARMLESS = new Set([".git", ".claude", ".DS_Store", ".idea", ".vscode"]);
|
|
234
280
|
const entries = fs.readdirSync(projectDir).filter((e) => e !== "." && e !== "..");
|
|
235
|
-
|
|
281
|
+
const blocking = entries.filter((e) => !HARMLESS.has(e) && !e.endsWith(".swp"));
|
|
282
|
+
if (blocking.length > 0 && !opts.force) {
|
|
283
|
+
const ignored = entries.filter((e) => !blocking.includes(e));
|
|
236
284
|
throw new Error(
|
|
237
|
-
`target directory ${projectDir} is not empty (pass force to overwrite)`
|
|
285
|
+
`target directory ${projectDir} is not empty (pass force to overwrite)\n` +
|
|
286
|
+
` Blocking entries: ${blocking.join(", ")}` +
|
|
287
|
+
(ignored.length > 0 ? `\n (ignored as harmless: ${ignored.join(", ")})` : "")
|
|
238
288
|
);
|
|
239
289
|
}
|
|
240
290
|
}
|
|
@@ -322,6 +372,14 @@ export async function scaffold(config, opts = {}) {
|
|
|
322
372
|
if (fs.existsSync(abs)) fs.rmSync(abs);
|
|
323
373
|
}
|
|
324
374
|
|
|
375
|
+
// Persist the resolved config as the project's spec-of-record. Until now the
|
|
376
|
+
// config was validated, consumed, and discarded — the only pre-code spec in
|
|
377
|
+
// the system evaporated at stamp time, so nothing could later answer "was
|
|
378
|
+
// this app built to its spec?" (tabs ↔ AppTab ↔ smoke consistency, upgrade
|
|
379
|
+
// intent, re-stamp/resume all need it). Committed with the app; hand-edits
|
|
380
|
+
// are visible spec changes, not drift.
|
|
381
|
+
writeSpecOfRecord(projectDir, config);
|
|
382
|
+
|
|
325
383
|
ok("Scaffold complete.");
|
|
326
384
|
|
|
327
385
|
// (f) verify gate
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Agent instructions
|
|
2
|
+
|
|
3
|
+
This repository is agent-first. The full working contract — the definition of done
|
|
4
|
+
(`node qa/verify.mjs` must PASS), the architecture gates, the testing pyramid, and the
|
|
5
|
+
device-free **UI feedback loop** (render every real screen headlessly and see exactly
|
|
6
|
+
what your edit changed) — lives in [CLAUDE.md](./CLAUDE.md).
|
|
7
|
+
|
|
8
|
+
Read CLAUDE.md before making changes. It applies to every coding agent, not only Claude.
|
package/template/CLAUDE.md
CHANGED
|
@@ -52,6 +52,38 @@ invariants the conformance gates enforce.
|
|
|
52
52
|
Commit it with your change — git history is the audit ledger. Binary artifacts under
|
|
53
53
|
`qa-artifacts/` are hashed into the receipt; never commit them.
|
|
54
54
|
|
|
55
|
+
## UI feedback loop — see what you build, without a device
|
|
56
|
+
|
|
57
|
+
<!-- >>> cmp:feature inspector -->
|
|
58
|
+
While building or changing ANY screen, use the preview loop instead of an emulator: it
|
|
59
|
+
renders this app's REAL screens (real DI, real theme, seeded data) headlessly in seconds
|
|
60
|
+
and tells you exactly what your edit changed.
|
|
61
|
+
|
|
62
|
+
**With the create-cmp plugin (cmp-inspector MCP tools):**
|
|
63
|
+
|
|
64
|
+
1. `preview { projectDir }` — once per session. Returns a live gallery URL (give it to
|
|
65
|
+
the human; it re-renders itself on every save) plus per-screen structural summaries
|
|
66
|
+
for you. Sources are watched; you never run Gradle by hand.
|
|
67
|
+
2. After each edit: `preview_status { waitForRender: true }` — blocks until the
|
|
68
|
+
render/compile outcome. `changedLastRender` names the screens your edit touched
|
|
69
|
+
(empty = the edit reached no screen); `lastErrorSource: "compile"` means the edit
|
|
70
|
+
didn't even build (the compiler's `e:` lines are in `lastError`).
|
|
71
|
+
3. `preview_diff { screen }` — one call proves the change: verdict `proven-clean` /
|
|
72
|
+
`changed-with-regressions` / `no-change`. No snapshot bookkeeping needed.
|
|
73
|
+
|
|
74
|
+
**Without the plugin:** `./gradlew :composeApp:renderScreens` renders every screen to
|
|
75
|
+
`composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
|
|
76
|
+
`node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
|
|
77
|
+
|
|
78
|
+
Screens come from `inspector/PreviewRegistry.kt` (desktopMain). **When you add a screen,
|
|
79
|
+
register it there** — a forced-state variant is just another entry (`"home@empty"`).
|
|
80
|
+
Assert on the `tree.json` structure; never read PNG bytes (pixels are for humans).
|
|
81
|
+
<!-- <<< cmp:feature inspector -->
|
|
82
|
+
<!-- >>> cmp:feature dev-client -->
|
|
83
|
+
For one interactive window instead of stills of every screen:
|
|
84
|
+
`./gradlew :composeApp:hotRunDesktop --auto` (Compose Hot Reload dev-client).
|
|
85
|
+
<!-- <<< cmp:feature dev-client -->
|
|
86
|
+
|
|
55
87
|
## Docs
|
|
56
88
|
|
|
57
89
|
[`README.md`](./README.md) (front page) · [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
|
package/template/README.md
CHANGED
|
@@ -17,6 +17,11 @@ follow the contract in [`CLAUDE.md`](./CLAUDE.md).
|
|
|
17
17
|
./gradlew :composeApp:hotRunDesktop --auto
|
|
18
18
|
<!-- <<< cmp:feature dev-client -->
|
|
19
19
|
|
|
20
|
+
<!-- >>> cmp:feature inspector -->
|
|
21
|
+
# Preview EVERY screen headlessly — no device, no emulator (agents: CLAUDE.md "UI feedback loop")
|
|
22
|
+
./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs
|
|
23
|
+
<!-- <<< cmp:feature inspector -->
|
|
24
|
+
|
|
20
25
|
# Unit tests (fast inner loop)
|
|
21
26
|
./gradlew :composeApp:desktopTest
|
|
22
27
|
|
|
@@ -136,6 +136,13 @@ kotlin {
|
|
|
136
136
|
implementation(compose.desktop.currentOs)
|
|
137
137
|
implementation(libs.kotlinx.coroutines.swing)
|
|
138
138
|
implementation(libs.ktor.client.cio)
|
|
139
|
+
// >>> cmp:feature inspector
|
|
140
|
+
// Headless semantics API (runDesktopComposeUiTest / onRoot) for the preview
|
|
141
|
+
// harness (inspector/PreviewHarness.kt) — renders real screens with no window.
|
|
142
|
+
// Desktop is a dev-only target, so shipping the test artifact here is deliberate.
|
|
143
|
+
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
|
|
144
|
+
implementation(compose.uiTest)
|
|
145
|
+
// <<< cmp:feature inspector
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
|
|
@@ -250,7 +257,19 @@ compose.resources {
|
|
|
250
257
|
|
|
251
258
|
// >>> cmp:feature room
|
|
252
259
|
room {
|
|
253
|
-
|
|
260
|
+
// Per-target schema directories, NOT one shared dir. With a single directory the
|
|
261
|
+
// copyRoomSchemas aggregation task requires every target's exported schema to be
|
|
262
|
+
// byte-identical — and the first entity edit after scaffold trips a cross-target
|
|
263
|
+
// checksum conflict against the stale intermediate of whichever target built last
|
|
264
|
+
// ("Inconsistency detected exporting Room schema files"). Per-target locations are
|
|
265
|
+
// exactly what that error's remediation asks for.
|
|
266
|
+
schemaDirectory("android", "$projectDir/schemas/android")
|
|
267
|
+
schemaDirectory("desktop", "$projectDir/schemas/desktop")
|
|
268
|
+
// >>> cmp:feature ios
|
|
269
|
+
schemaDirectory("iosSimulatorArm64", "$projectDir/schemas/iosSimulatorArm64")
|
|
270
|
+
schemaDirectory("iosX64", "$projectDir/schemas/iosX64")
|
|
271
|
+
schemaDirectory("iosArm64", "$projectDir/schemas/iosArm64")
|
|
272
|
+
// <<< cmp:feature ios
|
|
254
273
|
}
|
|
255
274
|
// <<< cmp:feature room
|
|
256
275
|
|
|
@@ -263,6 +282,52 @@ compose.desktop {
|
|
|
263
282
|
}
|
|
264
283
|
// <<< cmp:feature dev-client
|
|
265
284
|
|
|
285
|
+
// >>> cmp:feature inspector
|
|
286
|
+
// Headless screen previews — the project-wired tier-0 loop of the create-cmp inspector.
|
|
287
|
+
// Renders every screen in inspector/PreviewRegistry.kt (real DI, real theme, real data)
|
|
288
|
+
// to build/previews/<id>/{screen.png, tree.json} with NO device, emulator, or window,
|
|
289
|
+
// then qa/preview-gallery.mjs turns the output into one self-contained index.html:
|
|
290
|
+
//
|
|
291
|
+
// ./gradlew :composeApp:renderScreens # all screens
|
|
292
|
+
// ./gradlew :composeApp:renderScreens -Pscreen=home # one screen
|
|
293
|
+
// node qa/preview-gallery.mjs # build the gallery
|
|
294
|
+
//
|
|
295
|
+
// Parameters travel as -P properties -> system properties, NEVER via --args (Gradle's CLI
|
|
296
|
+
// parsing splits space-separated --args values into task names).
|
|
297
|
+
tasks.register<JavaExec>("renderScreens") {
|
|
298
|
+
group = "verification"
|
|
299
|
+
description = "Render registered screens headlessly to PNG + inspector-contract tree JSON."
|
|
300
|
+
val desktopCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
|
|
301
|
+
classpath(desktopCompilation.output.allOutputs, desktopCompilation.runtimeDependencyFiles)
|
|
302
|
+
mainClass.set("__PACKAGE__.inspector.PreviewHarnessKt")
|
|
303
|
+
systemProperty("java.awt.headless", "true")
|
|
304
|
+
systemProperty("screen", providers.gradleProperty("screen").getOrElse("all"))
|
|
305
|
+
systemProperty(
|
|
306
|
+
"out",
|
|
307
|
+
providers.gradleProperty("previewOut")
|
|
308
|
+
.getOrElse(layout.buildDirectory.dir("previews").get().asFile.absolutePath),
|
|
309
|
+
)
|
|
310
|
+
systemProperty("pngScale", providers.gradleProperty("pngScale").getOrElse("2"))
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Resident preview daemon (phase 2): a long-lived headless JVM serving /render on
|
|
314
|
+
// 127.0.0.1:9601 so warm re-renders skip the task cycle. Preferred launch is under
|
|
315
|
+
// Compose Hot Reload (saves hot-swap into the running daemon; needs the dev-client
|
|
316
|
+
// feature's Compose Hot Reload plugin):
|
|
317
|
+
//
|
|
318
|
+
// ./gradlew :composeApp:hotRunDesktop --mainClass=__PACKAGE__.inspector.PreviewDaemonKt --auto
|
|
319
|
+
//
|
|
320
|
+
// This plain variant runs on any JDK with no hot swap (restart to pick up new code):
|
|
321
|
+
tasks.register<JavaExec>("runPreviewDaemon") {
|
|
322
|
+
group = "verification"
|
|
323
|
+
description = "Run the resident preview daemon (loopback HTTP /render, no hot reload)."
|
|
324
|
+
val desktopCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
|
|
325
|
+
classpath(desktopCompilation.output.allOutputs, desktopCompilation.runtimeDependencyFiles)
|
|
326
|
+
mainClass.set("__PACKAGE__.inspector.PreviewDaemonKt")
|
|
327
|
+
systemProperty("java.awt.headless", "true")
|
|
328
|
+
}
|
|
329
|
+
// <<< cmp:feature inspector
|
|
330
|
+
|
|
266
331
|
// Evidence integrity: golden-tree baselines (qa/golden) and the UPDATE_GOLDEN capture flag are
|
|
267
332
|
// REAL inputs of the JVM test tier, but Gradle can't see either on its own — baselines are read
|
|
268
333
|
// at runtime, not compiled, and env vars aren't tracked. Undeclared, the build cache will happily
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package __PACKAGE__.core.format
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KMP-safe formatting helpers.
|
|
5
|
+
*
|
|
6
|
+
* `String.format` / `"%02d".format(...)` are JVM-only — they compile in `androidMain` but do
|
|
7
|
+
* not exist in `commonMain`, and reaching for them is the single most common first-week
|
|
8
|
+
* porting mistake in a shared module. These cover the cases that actually come up; add here
|
|
9
|
+
* rather than sprinkling `padStart` call sites.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Pad an Int to two digits: 7 -> "07". The `%02d` you were about to write. */
|
|
13
|
+
fun pad2(n: Int): String = n.toString().padStart(2, '0')
|
|
14
|
+
|
|
15
|
+
/** "HH:mm" from minutes-since-midnight: 555 -> "09:15". */
|
|
16
|
+
fun clockLabel(minutesOfDay: Int): String {
|
|
17
|
+
val m = ((minutesOfDay % (24 * 60)) + 24 * 60) % (24 * 60) // wrap + never negative
|
|
18
|
+
return "${pad2(m / 60)}:${pad2(m % 60)}"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fixed decimal places without java.text: 12.5 -> "12.5" (1 dp). Rounds half away from zero
|
|
23
|
+
* via floor(abs + 0.5) — deterministic on every backend (kotlin.math.round's tie behavior and
|
|
24
|
+
* `%.Nf` locale handling both vary). For layout-stable numeric UI text, not accounting math.
|
|
25
|
+
*/
|
|
26
|
+
fun fixed(value: Double, decimals: Int = 1): String {
|
|
27
|
+
require(decimals >= 0) { "decimals must be >= 0" }
|
|
28
|
+
var factor = 1L
|
|
29
|
+
repeat(decimals) { factor *= 10 }
|
|
30
|
+
val scaled = kotlin.math.floor(kotlin.math.abs(value) * factor + 0.5).toLong()
|
|
31
|
+
val sign = if (value < 0 && scaled != 0L) "-" else ""
|
|
32
|
+
if (decimals == 0) return "$sign$scaled"
|
|
33
|
+
val whole = scaled / factor
|
|
34
|
+
val frac = (scaled % factor).toString().padStart(decimals, '0')
|
|
35
|
+
return "$sign$whole.$frac"
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package __PACKAGE__.core.format
|
|
2
|
+
|
|
3
|
+
import kotlin.test.Test
|
|
4
|
+
import kotlin.test.assertEquals
|
|
5
|
+
|
|
6
|
+
class FormatTest {
|
|
7
|
+
|
|
8
|
+
@Test
|
|
9
|
+
fun pad2_pads_single_digits() {
|
|
10
|
+
assertEquals("07", pad2(7))
|
|
11
|
+
assertEquals("00", pad2(0))
|
|
12
|
+
assertEquals("15", pad2(15))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
fun clockLabel_formats_minutes_of_day() {
|
|
17
|
+
assertEquals("09:15", clockLabel(9 * 60 + 15))
|
|
18
|
+
assertEquals("00:00", clockLabel(0))
|
|
19
|
+
assertEquals("23:59", clockLabel(23 * 60 + 59))
|
|
20
|
+
// wraps past midnight and never goes negative
|
|
21
|
+
assertEquals("00:30", clockLabel(24 * 60 + 30))
|
|
22
|
+
assertEquals("23:30", clockLabel(-30))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun fixed_renders_stable_decimals() {
|
|
27
|
+
// Binary-exact inputs only — 0.1-style values are not representable and would make
|
|
28
|
+
// these assertions depend on the platform's double formatting.
|
|
29
|
+
assertEquals("12.5", fixed(12.5, 1))
|
|
30
|
+
assertEquals("0.3", fixed(0.25, 1)) // half rounds away from zero
|
|
31
|
+
assertEquals("-0.8", fixed(-0.75, 1))
|
|
32
|
+
assertEquals("3.00", fixed(3.0, 2))
|
|
33
|
+
assertEquals("13", fixed(12.5, 0))
|
|
34
|
+
assertEquals("0.0", fixed(0.0, 1))
|
|
35
|
+
}
|
|
36
|
+
}
|