create-cmp-cli 0.17.0 → 0.17.1
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/package.json +1 -1
- package/src/commands/doctor.mjs +101 -2
- package/src/lib/hooks.mjs +17 -4
- package/src/lib/project-doctor.mjs +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app 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": {
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -7,11 +7,13 @@
|
|
|
7
7
|
// [--target-dir <dir>] [--fix]
|
|
8
8
|
//
|
|
9
9
|
// --fix applies only SAFE heals (write local.properties from ANDROID_HOME, add
|
|
10
|
-
// ksp.useKSP2=true
|
|
10
|
+
// ksp.useKSP2=true, wire the walk into .claude/settings.json); everything else
|
|
11
|
+
// prints the exact manual step.
|
|
11
12
|
|
|
12
13
|
import fs from "node:fs";
|
|
13
14
|
import os from "node:os";
|
|
14
15
|
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
15
17
|
|
|
16
18
|
import { flagBool } from "../lib/args.mjs";
|
|
17
19
|
import { colors, ok } from "../lib/log.mjs";
|
|
@@ -71,6 +73,65 @@ function freeDiskBytes() {
|
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
/** This engine checkout root — the template it ships is the wiring of record. */
|
|
77
|
+
const ENGINE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The walk wiring the CURRENT engine template declares: the statusLine object
|
|
81
|
+
* and the UserPromptSubmit hook groups that invoke qa/walk-status.mjs. Read from
|
|
82
|
+
* template/.claude/settings.json rather than duplicated here, so the heal cannot
|
|
83
|
+
* drift from what a fresh scaffold gets.
|
|
84
|
+
* @returns {{statusLine: object|null, promptSubmit: Array|null}}
|
|
85
|
+
*/
|
|
86
|
+
export function templateWalkWiring() {
|
|
87
|
+
const raw = readIfExists(path.join(ENGINE_ROOT, "template", ".claude", "settings.json"));
|
|
88
|
+
if (raw === null) return { statusLine: null, promptSubmit: null };
|
|
89
|
+
try {
|
|
90
|
+
const t = JSON.parse(raw);
|
|
91
|
+
const statusLine = invokesWalk(t.statusLine) ? t.statusLine : null;
|
|
92
|
+
const groups = (t.hooks?.UserPromptSubmit ?? []).filter((g) =>
|
|
93
|
+
(g?.hooks ?? []).some(invokesWalk)
|
|
94
|
+
);
|
|
95
|
+
return { statusLine, promptSubmit: groups.length > 0 ? groups : null };
|
|
96
|
+
} catch {
|
|
97
|
+
return { statusLine: null, promptSubmit: null };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Does this settings entry ({type, command}) actually run the walk? */
|
|
102
|
+
function invokesWalk(entry) {
|
|
103
|
+
return String(entry?.command ?? "").includes("walk-status.mjs");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is the walk installed, and does .claude/settings.json invoke it? The machinery
|
|
108
|
+
* and the wiring live in separately-owned files (lane vs app config), so they can
|
|
109
|
+
* and do come apart — see the walk-wiring finding in project-doctor.mjs.
|
|
110
|
+
*/
|
|
111
|
+
export function gatherWalkInputs(projectDir) {
|
|
112
|
+
const scriptPresent = fs.existsSync(path.join(projectDir, "qa", "walk-status.mjs"));
|
|
113
|
+
if (!scriptPresent) return null; // not a walk-carrying lane — nothing to say
|
|
114
|
+
const raw = readIfExists(path.join(projectDir, ".claude", "settings.json"));
|
|
115
|
+
if (raw === null) {
|
|
116
|
+
return { scriptPresent, settingsPresent: false, statusLine: false, promptHook: false };
|
|
117
|
+
}
|
|
118
|
+
let settings;
|
|
119
|
+
try {
|
|
120
|
+
settings = JSON.parse(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
// Unparseable settings invoke nothing, which is exactly what we report.
|
|
123
|
+
return { scriptPresent, settingsPresent: true, statusLine: false, promptHook: false };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
scriptPresent,
|
|
127
|
+
settingsPresent: true,
|
|
128
|
+
statusLine: invokesWalk(settings.statusLine),
|
|
129
|
+
promptHook: (settings.hooks?.UserPromptSubmit ?? []).some((g) =>
|
|
130
|
+
(g?.hooks ?? []).some(invokesWalk)
|
|
131
|
+
),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
74
135
|
/** Gather filesystem/env inputs for the pure diagnosis. */
|
|
75
136
|
export function gatherProjectInputs(projectDir) {
|
|
76
137
|
const toml = readIfExists(path.join(projectDir, "gradle", "libs.versions.toml"));
|
|
@@ -112,6 +173,7 @@ export function gatherProjectInputs(projectDir) {
|
|
|
112
173
|
freeDiskBytes: freeDiskBytes(),
|
|
113
174
|
inspectorHits,
|
|
114
175
|
inspectorCatalog,
|
|
176
|
+
walk: gatherWalkInputs(projectDir),
|
|
115
177
|
};
|
|
116
178
|
}
|
|
117
179
|
|
|
@@ -146,7 +208,7 @@ function scanInspectorSources(projectDir) {
|
|
|
146
208
|
}
|
|
147
209
|
|
|
148
210
|
/** Apply the SAFE auto-heals for --fix. Returns ids of findings it fixed. */
|
|
149
|
-
function applySafeFixes(projectDir, findings, inputs) {
|
|
211
|
+
export function applySafeFixes(projectDir, findings, inputs) {
|
|
150
212
|
const fixed = [];
|
|
151
213
|
for (const f of findings) {
|
|
152
214
|
if (!f.fix || !f.fix.auto || f.level === "ok") continue;
|
|
@@ -164,6 +226,43 @@ function applySafeFixes(projectDir, findings, inputs) {
|
|
|
164
226
|
}
|
|
165
227
|
}
|
|
166
228
|
|
|
229
|
+
if (f.id === "walk-wiring") {
|
|
230
|
+
const { statusLine, promptSubmit } = templateWalkWiring();
|
|
231
|
+
const target = path.join(projectDir, ".claude", "settings.json");
|
|
232
|
+
const raw = readIfExists(target);
|
|
233
|
+
let settings = {};
|
|
234
|
+
if (raw !== null) {
|
|
235
|
+
try {
|
|
236
|
+
settings = JSON.parse(raw);
|
|
237
|
+
} catch {
|
|
238
|
+
// Never overwrite settings we could not read — that is the app's file.
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
let changed = false;
|
|
243
|
+
if (statusLine && !invokesWalk(settings.statusLine)) {
|
|
244
|
+
// Only claim an unclaimed slot: an app that set its OWN status line keeps it.
|
|
245
|
+
if (!settings.statusLine) {
|
|
246
|
+
settings.statusLine = statusLine;
|
|
247
|
+
changed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (promptSubmit) {
|
|
251
|
+
settings.hooks = settings.hooks ?? {};
|
|
252
|
+
const existing = settings.hooks.UserPromptSubmit ?? [];
|
|
253
|
+
if (!existing.some((g) => (g?.hooks ?? []).some(invokesWalk))) {
|
|
254
|
+
settings.hooks.UserPromptSubmit = [...existing, ...promptSubmit];
|
|
255
|
+
changed = true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (changed) {
|
|
259
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
260
|
+
fs.writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`);
|
|
261
|
+
ok("--fix: wired the walk into .claude/settings.json (statusLine + UserPromptSubmit)");
|
|
262
|
+
fixed.push(f.id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
167
266
|
if (f.id === "ksp2-flag") {
|
|
168
267
|
const target = path.join(projectDir, "gradle.properties");
|
|
169
268
|
const existing = inputs.gradleProperties ?? "";
|
package/src/lib/hooks.mjs
CHANGED
|
@@ -42,7 +42,10 @@ export function isEnforcementEvent(event) {
|
|
|
42
42
|
return ENFORCEMENT_EVENTS.has(event);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Does this entry's command presuppose the verify lane (`qa/`)? Takes anything
|
|
47
|
+
* settings.json can hold a command in — a hook, or the top-level `statusLine`.
|
|
48
|
+
*/
|
|
46
49
|
export function referencesLane(hook) {
|
|
47
50
|
return String(hook?.command ?? "").includes("qa/");
|
|
48
51
|
}
|
|
@@ -118,12 +121,20 @@ export function sessionStartCommand(context) {
|
|
|
118
121
|
|
|
119
122
|
/**
|
|
120
123
|
* The minimal-mode hook set, DERIVED from the full one rather than kept as a
|
|
121
|
-
* second file to hold in sync (light is a filter, not a fork).
|
|
124
|
+
* second file to hold in sync (light is a filter, not a fork). Four edits:
|
|
122
125
|
*
|
|
123
126
|
* (a) enforcement goes — the Stop hook is Act 3;
|
|
124
127
|
* (b) lane-advisory goes — a nudge naming qa/ presupposes the lane;
|
|
125
128
|
* (c) SessionStart says what is true HERE — `sessionContext` describes what
|
|
126
|
-
* this scaffold carries and the one command that adds the rest
|
|
129
|
+
* this scaffold carries and the one command that adds the rest;
|
|
130
|
+
* (d) a lane-referencing `statusLine` goes. It is not a hook, so the
|
|
131
|
+
* classifier above never saw it, and the first cut of the walk shipped a
|
|
132
|
+
* minimal scaffold a status line reading `qa/walk-status.mjs` — a file
|
|
133
|
+
* minimal deletes. The command is guarded (`test -f … || true`) so it
|
|
134
|
+
* printed nothing rather than erroring, which is exactly what made it
|
|
135
|
+
* survive review: dead config that fails silently. The lane-reference
|
|
136
|
+
* rule is the same one that drops lane-advisory hooks; it just has to be
|
|
137
|
+
* applied to every surface that carries a command, not only to `hooks`.
|
|
127
138
|
*
|
|
128
139
|
* @param {object} settings parsed .claude/settings.json content
|
|
129
140
|
* @param {object} opts
|
|
@@ -131,7 +142,9 @@ export function sessionStartCommand(context) {
|
|
|
131
142
|
*/
|
|
132
143
|
export function minimalHookSettings(settings, { sessionContext }) {
|
|
133
144
|
const out = filterHooks(settings, (event, hook) => classifyHook(event, hook) !== "advisory");
|
|
134
|
-
if (!out || typeof out
|
|
145
|
+
if (!out || typeof out !== "object") return out;
|
|
146
|
+
if (referencesLane(out.statusLine)) delete out.statusLine;
|
|
147
|
+
if (typeof out.hooks !== "object" || out.hooks === null) return out;
|
|
135
148
|
for (const group of out.hooks.SessionStart ?? []) {
|
|
136
149
|
if (!Array.isArray(group?.hooks)) continue;
|
|
137
150
|
for (const hook of group.hooks) hook.command = sessionStartCommand(sessionContext);
|
|
@@ -36,6 +36,10 @@ export const DISK_WARN_BYTES = 3 * GIB;
|
|
|
36
36
|
* @param {string[]|null} [input.inspectorHits] relative (posix) paths of Kotlin sources that
|
|
37
37
|
* reference the live-inspector endpoint (`/inspect/` or `InspectorHttpServer`);
|
|
38
38
|
* null = scan skipped (no composeApp sources), [] = project has no inspector code.
|
|
39
|
+
* @param {{scriptPresent:boolean, settingsPresent:boolean, statusLine:boolean,
|
|
40
|
+
* promptHook:boolean}|null} [input.walk] the walk's wiring: is
|
|
41
|
+
* qa/walk-status.mjs installed, and does .claude/settings.json actually
|
|
42
|
+
* INVOKE it (statusLine + UserPromptSubmit)? null = skip the check.
|
|
39
43
|
* @param {{catalog:string, theme:string}|null} [input.inspectorCatalog] the stamped
|
|
40
44
|
* InspectorCatalog.kt content + concatenated theme sources (Tokens.kt/Theme.kt) for
|
|
41
45
|
* the declared-token drift tripwire; null = skip.
|
|
@@ -55,6 +59,7 @@ export function diagnoseProject(input) {
|
|
|
55
59
|
freeDiskBytes,
|
|
56
60
|
inspectorHits = null,
|
|
57
61
|
inspectorCatalog = null,
|
|
62
|
+
walk = null,
|
|
58
63
|
} = input;
|
|
59
64
|
|
|
60
65
|
// --- version catalog ------------------------------------------------------
|
|
@@ -313,6 +318,49 @@ export function diagnoseProject(input) {
|
|
|
313
318
|
}
|
|
314
319
|
}
|
|
315
320
|
|
|
321
|
+
// --- the walk: installed but unwired ----------------------------------------
|
|
322
|
+
// qa/walk-status.mjs is inert on its own. What renders it is .claude/settings.json:
|
|
323
|
+
// a statusLine (the ambient "where are we") and a UserPromptSubmit hook (the
|
|
324
|
+
// per-turn position injected into the agent). Both are APP-OWNED config, so an app
|
|
325
|
+
// that hand-edited settings.json can take the machinery on upgrade and lose the
|
|
326
|
+
// wiring — and the failure mode is silence, which is precisely the problem the walk
|
|
327
|
+
// exists to fix. Nothing else in the system can notice, so doctor does.
|
|
328
|
+
if (walk !== null && walk.scriptPresent) {
|
|
329
|
+
const missing = [
|
|
330
|
+
!walk.statusLine ? "no statusLine" : null,
|
|
331
|
+
!walk.promptHook ? "no UserPromptSubmit hook" : null,
|
|
332
|
+
].filter(Boolean);
|
|
333
|
+
if (missing.length === 0) {
|
|
334
|
+
findings.push({
|
|
335
|
+
id: "walk-wiring",
|
|
336
|
+
level: "ok",
|
|
337
|
+
title: "The walk is wired",
|
|
338
|
+
detail:
|
|
339
|
+
"qa/walk-status.mjs is installed and .claude/settings.json invokes it from both the " +
|
|
340
|
+
"status line and UserPromptSubmit.",
|
|
341
|
+
});
|
|
342
|
+
} else {
|
|
343
|
+
findings.push({
|
|
344
|
+
id: "walk-wiring",
|
|
345
|
+
level: "warn",
|
|
346
|
+
title: "The walk is installed but not wired up",
|
|
347
|
+
detail:
|
|
348
|
+
"qa/walk-status.mjs is present, but " +
|
|
349
|
+
(walk.settingsPresent
|
|
350
|
+
? `.claude/settings.json does not invoke it (${missing.join(", ")}).`
|
|
351
|
+
: "there is no .claude/settings.json to invoke it from.") +
|
|
352
|
+
" Nothing will show which stage a feature is at, or tell the agent where it is — " +
|
|
353
|
+
"the walk runs nowhere. Running node qa/walk-status.mjs by hand still works.",
|
|
354
|
+
fix: {
|
|
355
|
+
auto: true,
|
|
356
|
+
description:
|
|
357
|
+
"Add the statusLine and UserPromptSubmit entries to .claude/settings.json " +
|
|
358
|
+
"(copied from the engine template; existing hooks are left untouched).",
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
316
364
|
return findings;
|
|
317
365
|
}
|
|
318
366
|
|