mlola-ui 1.11.0 → 1.12.2

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.
@@ -55,6 +55,11 @@ const quote = (value) => {
55
55
  const escapeSegment = (segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1");
56
56
  const pointer = (base, ...segments) => segments.reduce((path, segment) => `${path}/${escapeSegment(segment)}`, base);
57
57
  const list = (values) => values.map((value) => quote(value)).join(", ");
58
+ /** A noun as its owner and as more than one: a column's, columns; a series', series. */
59
+ const owned = (noun) => (noun.endsWith("s") ? `${noun}'` : `${noun}'s`);
60
+ const many = (noun) => (noun.endsWith("s") ? noun : `${noun}s`);
61
+ /** What one entry of a "columns" prop takes beside its key and label: named values, or true and false. */
62
+ const entryFields = (prop) => ({ ...(prop.align ? { align: prop.align } : {}), ...prop.fields });
58
63
 
59
64
  /**
60
65
  * Reads what an agent or a file holds: one message, an array of messages,
@@ -467,9 +472,14 @@ function checkProp(rules, surfaceId, type, name, prop, value, at, issues) {
467
472
  case "options":
468
473
  return checkOptions(rules, surfaceId, type, prop, value, at, issues);
469
474
  case "columns":
470
- return checkColumns(rules, surfaceId, prop, value, at, issues);
475
+ return checkColumns(rules, surfaceId, type, name, prop, value, at, issues);
471
476
  case "rows":
472
- return checkRows(rules, surfaceId, value, at, issues);
477
+ return checkRows(rules, surfaceId, name, prop, value, at, issues);
478
+ case "key":
479
+ if (typeof value === "string" && COLUMN_KEY.test(value) && !UNSAFE_SEGMENTS.has(value)) return;
480
+ return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} names a field of each row: letters, digits, _ or -, like "month"; got ${quote(value)}.`));
481
+ case "numbers":
482
+ return checkNumbers(rules, surfaceId, type, name, prop, value, at, issues);
473
483
  default:
474
484
  return issues.errors.push(failure(surfaceId, at, `${type} ${quote(name)} has a kind this validator does not know (${prop.kind}).`));
475
485
  }
@@ -618,45 +628,57 @@ function checkOptions(rules, surfaceId, type, prop, value, at, issues) {
618
628
  }
619
629
  }
620
630
 
621
- function checkColumns(rules, surfaceId, prop, value, at, issues) {
631
+ function checkColumns(rules, surfaceId, type, name, prop, value, at, issues) {
632
+ const item = prop.item ?? "column";
633
+ const fields = entryFields(prop);
622
634
  if (!Array.isArray(value) || value.length < (prop.minItems ?? 1)) {
623
- issues.errors.push(failure(surfaceId, at, `"columns" is a non-empty array of {"key", "label", "align"?}.`));
635
+ issues.errors.push(failure(surfaceId, at, `${quote(name)} is a non-empty array of {"key", "label"${Object.keys(fields).map((field) => `, ${quote(field)}?`).join("")}}.`));
624
636
  return;
625
637
  }
626
- if (value.length > rules.limits.columns) {
627
- issues.errors.push(failure(surfaceId, at, `A table shows at most ${rules.limits.columns} columns; more cannot be read on a phone.`));
638
+ const max = prop.maxItems ?? rules.limits.columns;
639
+ if (value.length > max) {
640
+ issues.errors.push(failure(surfaceId, at, prop.maxItems ? `${type} takes at most ${max} ${many(item)}; this is ${value.length}.` : `A table shows at most ${max} columns; more cannot be read on a phone.`));
628
641
  return;
629
642
  }
630
643
  const seen = new Set();
631
644
  for (const [index, column] of value.entries()) {
632
645
  const columnAt = pointer(at, index);
633
646
  if (!isObject(column)) {
634
- issues.errors.push(failure(surfaceId, columnAt, `A column is {"key": "…", "label": "…"}; got ${typeName(column)}.`));
647
+ issues.errors.push(failure(surfaceId, columnAt, `A ${item} is {"key": "…", "label": "…"}; got ${typeName(column)}.`));
635
648
  continue;
636
649
  }
637
- onlyKeys(column, ["key", "label", "align"], columnAt, surfaceId, issues, "A column");
638
- if (typeof column.key !== "string" || !COLUMN_KEY.test(column.key) || UNSAFE_SEGMENTS.has(column.key)) issues.errors.push(failure(surfaceId, pointer(columnAt, "key"), `A column's "key" names the row field it shows: letters, digits, _ or -, like "order".`));
639
- else if (seen.has(column.key)) issues.errors.push(failure(surfaceId, pointer(columnAt, "key"), `Two columns have the key ${quote(column.key)}.`));
650
+ onlyKeys(column, ["key", "label", ...Object.keys(fields)], columnAt, surfaceId, issues, `A ${item}`);
651
+ if (typeof column.key !== "string" || !COLUMN_KEY.test(column.key) || UNSAFE_SEGMENTS.has(column.key)) issues.errors.push(failure(surfaceId, pointer(columnAt, "key"), `A ${owned(item)} "key" names the row field it shows: letters, digits, _ or -, like "order".`));
652
+ else if (seen.has(column.key)) issues.errors.push(failure(surfaceId, pointer(columnAt, "key"), `Two ${many(item)} have the key ${quote(column.key)}.`));
640
653
  else seen.add(column.key);
641
- checkText(rules, surfaceId, column.label, pointer(columnAt, "label"), { maxLength: rules.limits.labelLength }, true, issues, `The column's "label"`);
642
- if (column.align !== undefined && !prop.align.values.includes(column.align)) issues.errors.push(failure(surfaceId, pointer(columnAt, "align"), `"align" is ${list(prop.align.values)}.`));
654
+ checkText(rules, surfaceId, column.label, pointer(columnAt, "label"), { maxLength: rules.limits.labelLength }, true, issues, `The ${owned(item)} "label"`);
655
+ for (const [field, spec] of Object.entries(fields)) {
656
+ const given = column[field];
657
+ if (given === undefined || (spec.values ? spec.values.includes(given) : typeof given === "boolean")) continue;
658
+ issues.errors.push(failure(surfaceId, pointer(columnAt, field), spec.values ? `${quote(field)} is ${list(spec.values)}.` : `${quote(field)} is true or false.`));
659
+ }
643
660
  }
644
661
  }
645
662
 
646
- function checkRows(rules, surfaceId, value, at, issues) {
663
+ function checkRows(rules, surfaceId, name, prop, value, at, issues) {
647
664
  if (binding(rules, surfaceId, value, at, issues)) return;
648
665
  if (!Array.isArray(value)) {
649
- issues.errors.push(failure(surfaceId, at, `"rows" is an array of objects, or {"path": "/…"} to one in the data model; got ${typeName(value)}.`));
666
+ issues.errors.push(failure(surfaceId, at, `${quote(name)} is an array of objects, or {"path": "/…"} to one in the data model; got ${typeName(value)}.`));
667
+ return;
668
+ }
669
+ if (value.length < (prop.minItems ?? 0)) {
670
+ issues.errors.push(failure(surfaceId, at, `${quote(name)} needs at least ${prop.minItems} ${prop.minItems === 1 ? "row" : "rows"}.`));
650
671
  return;
651
672
  }
652
- if (value.length > rules.limits.rows) {
653
- issues.errors.push(failure(surfaceId, at, `A table holds at most ${rules.limits.rows} rows; page through the rest.`));
673
+ const max = Math.min(prop.maxItems ?? rules.limits.rows, rules.limits.rows);
674
+ if (value.length > max) {
675
+ issues.errors.push(failure(surfaceId, at, `${quote(name)} holds at most ${max} rows; ${prop.maxItems ? "keep the ones that matter" : "page through the rest"}.`));
654
676
  return;
655
677
  }
656
678
  for (const [index, row] of value.entries()) {
657
679
  const rowAt = pointer(at, index);
658
680
  if (!isObject(row)) {
659
- issues.errors.push(failure(surfaceId, rowAt, `A row is an object keyed by column key; got ${typeName(row)}.`));
681
+ issues.errors.push(failure(surfaceId, rowAt, `A row is an object of its fields, like {"name": "…"}; got ${typeName(row)}.`));
660
682
  continue;
661
683
  }
662
684
  for (const [key, cell] of Object.entries(row)) {
@@ -667,6 +689,52 @@ function checkRows(rules, surfaceId, value, at, issues) {
667
689
  }
668
690
  }
669
691
 
692
+ function checkNumbers(rules, surfaceId, type, name, prop, value, at, issues) {
693
+ if (binding(rules, surfaceId, value, at, issues)) return;
694
+ const label = `${type} ${quote(name)}`;
695
+ if (!Array.isArray(value)) {
696
+ issues.errors.push(failure(surfaceId, at, `${label} is an array of numbers, or {"path": "/…"} to one in the data model; got ${typeName(value)}.`));
697
+ return;
698
+ }
699
+ if (value.length < (prop.minItems ?? 0) || value.length > rules.limits.rows) {
700
+ issues.errors.push(failure(surfaceId, at, `${label} holds ${prop.minItems ?? 0} to ${rules.limits.rows} numbers; this is ${value.length}.`));
701
+ return;
702
+ }
703
+ const odd = value.findIndex((entry) => typeof entry !== "number" || !Number.isFinite(entry));
704
+ if (odd !== -1) issues.errors.push(failure(surfaceId, pointer(at, odd), `${label} holds numbers only; this one is ${quote(value[odd])}.`));
705
+ }
706
+
707
+ /**
708
+ * What a prop reads from the rows beside it (`rows` names that prop): a name
709
+ * in every row, a number wherever one is drawn, and somewhere to write when
710
+ * a person may edit. Rows in the data model are the renderer's to read with
711
+ * care; rows written into the component are held here.
712
+ */
713
+ function checkRowsRead(surfaceId, type, definition, component, at, issues) {
714
+ for (const [name, prop] of Object.entries(definition.props)) {
715
+ const given = component[name];
716
+ if (!prop.rows || given === undefined) continue;
717
+ const rows = component[prop.rows];
718
+ const entries = Array.isArray(given) ? given.filter(isObject) : [];
719
+ const writes = Object.entries(prop.fields ?? {}).filter(([, field]) => field.writes).map(([field]) => field);
720
+ const written = Array.isArray(given) && !(isObject(rows) && "path" in rows) ? given.findIndex((entry) => isObject(entry) && writes.some((field) => entry[field] === true)) : -1;
721
+ if (written >= 0) issues.errors.push(failure(surfaceId, pointer(at, name, written), `What a person enters here is written to ${quote(prop.rows)}, so bind it to the data model: {"path": "/…"}, with the rows in updateDataModel.`));
722
+ // A key names each row or holds its number; a list of them is read only where it is drawn as numbers.
723
+ if (!Array.isArray(rows) || (prop.kind !== "key" && !prop.numeric)) continue;
724
+ for (const key of prop.kind === "key" ? [given] : entries.map((entry) => entry.key)) {
725
+ // A key that is no field name was reported where it was written.
726
+ if (typeof key !== "string" || !COLUMN_KEY.test(key) || UNSAFE_SEGMENTS.has(key)) continue;
727
+ const fits = (value) => (prop.numeric ? value === undefined || value === null || (typeof value === "number" && Number.isFinite(value) && value >= (prop.minimum ?? -Infinity)) : (typeof value === "string" && value.trim() !== "") || typeof value === "number");
728
+ const odd = rows.findIndex((row) => isObject(row) && !fits(own(row, key)));
729
+ if (odd === -1) continue;
730
+ const value = own(rows[odd], key);
731
+ issues.errors.push(failure(surfaceId, value === undefined ? pointer(at, prop.rows, odd) : pointer(at, prop.rows, odd, key), prop.numeric
732
+ ? `${type} draws ${quote(key)} as a number${prop.minimum === undefined ? "" : ` of at least ${prop.minimum}`}; this row has ${quote(value)}. Write the number itself, or leave it out for a gap.`
733
+ : `${type} names each row by its ${quote(key)}; this row has ${value === undefined ? "none" : quote(value)}.`));
734
+ }
735
+ }
736
+ }
737
+
670
738
  /** Rules between props of one component: a range that holds its value, a value among the options. */
671
739
  function checkValues(rules, surfaceId, type, definition, component, at, issues) {
672
740
  const literal = (name) => (typeof component[name] === "number" ? component[name] : definition.props[name]?.default);
@@ -693,6 +761,7 @@ function checkValues(rules, surfaceId, type, definition, component, at, issues)
693
761
  const values = component.options.filter(isObject).map((option) => option.value);
694
762
  if (!values.includes(component.value)) issues.errors.push(failure(surfaceId, pointer(at, "value"), `${type} "value" ${quote(component.value)} is not one of its options (${list(values)}).`));
695
763
  }
764
+ checkRowsRead(surfaceId, type, definition, component, at, issues);
696
765
  }
697
766
 
698
767
  /* The tree ---------------------------------------------------------------- */
@@ -883,8 +952,13 @@ export { childIds, getAt, isObject, setAt, unescapeSegment };
883
952
 
884
953
  /* For people and agents --------------------------------------------------- */
885
954
 
886
- /** The catalog as a guide an agent reads before composing a surface. */
887
- export function renderCatalogGuide(rules) {
955
+ /**
956
+ * The catalog as a guide an agent reads before composing a surface. With
957
+ * `themes: false` it leaves themes out, for a surface that takes the theme
958
+ * of the page it is shown on (a renderer's `theme: "inherit"`).
959
+ */
960
+ export function renderCatalogGuide(rules, { themes = true } = {}) {
961
+ const look = themes ? `, "theme": {"name": ${quote(rules.defaultTheme)}, "mode": "system"}` : "";
888
962
  const lines = [
889
963
  `# ${rules.title}`,
890
964
  "",
@@ -892,13 +966,13 @@ export function renderCatalogGuide(rules) {
892
966
  "",
893
967
  `Compose a surface as A2UI messages (version ${list(rules.protocolVersions)}):`,
894
968
  "",
895
- `1. {"version": "v0.9.1", "createSurface": {"surfaceId": "…", "catalogId": ${quote(rules.catalogId)}, "theme": {"name": ${quote(rules.defaultTheme)}, "mode": "system"}}}`,
969
+ `1. {"version": "v0.9.1", "createSurface": {"surfaceId": "…", "catalogId": ${quote(rules.catalogId)}${look}}}`,
896
970
  `2. {"version": "v0.9.1", "updateComponents": {"surfaceId": "…", "components": [{"id": "root", "component": "Stack", "children": […]}, …]}}`,
897
971
  `3. {"version": "v0.9.1", "updateDataModel": {"surfaceId": "…", "path": "/form", "value": {…}}} for the values inputs bind to.`,
898
972
  "",
899
973
  "Components are a flat list; containers name their children by id, and exactly one component has the id \"root\". Never set a color, size, class or style: pick the named values below.",
900
974
  "",
901
- `Themes: ${rules.themes.join(", ")} (default ${rules.defaultTheme}); modes: ${rules.modes.join(", ")}.`,
975
+ themes ? `Themes: ${rules.themes.join(", ")} (default ${rules.defaultTheme}); modes: ${rules.modes.join(", ")}.` : `Leave "theme" out of createSurface: the surface takes the theme of the page it is shown on.`,
902
976
  "",
903
977
  "## Data and actions",
904
978
  "",
@@ -928,7 +1002,7 @@ export function renderCatalogGuide(rules) {
928
1002
  if (rules.examples?.length) {
929
1003
  lines.push("## Examples", "");
930
1004
  for (const example of rules.examples) {
931
- lines.push(`### ${example.title}`, "", example.description, "", "```json", "[", example.messages.map((message) => ` ${JSON.stringify(message)}`).join(",\n"), "]", "```", "");
1005
+ lines.push(`### ${example.title}`, "", example.description, "", "```json", "[", example.messages.map((message) => ` ${JSON.stringify(themes ? message : unthemed(message))}`).join(",\n"), "]", "```", "");
932
1006
  }
933
1007
  }
934
1008
  lines.push(
@@ -945,6 +1019,12 @@ export function renderCatalogGuide(rules) {
945
1019
  return `${lines.join("\n")}\n`;
946
1020
  }
947
1021
 
1022
+ /** A message as it reads when the page gives the theme: createSurface without one. */
1023
+ function unthemed(message) {
1024
+ if (!isObject(message) || !isObject(message.createSurface) || !("theme" in message.createSurface)) return message;
1025
+ return { ...message, createSurface: Object.fromEntries(Object.entries(message.createSurface).filter(([key]) => key !== "theme")) };
1026
+ }
1027
+
948
1028
  function describeKind(spec) {
949
1029
  switch (spec.kind) {
950
1030
  case "enum":
@@ -965,10 +1045,16 @@ function describeKind(spec) {
965
1045
  return `{"event": {"name", "context"}}`;
966
1046
  case "options":
967
1047
  return `an array of {"label", "value"}, or {"path"}${spec.minItems ? `, at least ${spec.minItems}` : ""}`;
968
- case "columns":
969
- return `an array of {"key", "label", "align"?}; align is ${list(spec.align.values)}`;
1048
+ case "columns": {
1049
+ const fields = Object.entries(entryFields(spec));
1050
+ return `an array of {"key", "label"${fields.map(([field]) => `, ${quote(field)}?`).join("")}}${spec.maxItems ? `, at most ${spec.maxItems}` : ""}${fields.map(([field, entry]) => `; ${field} is ${entry.values ? list(entry.values) : "true or false"}`).join("")}`;
1051
+ }
970
1052
  case "rows":
971
- return `an array of objects keyed by column key, or {"path"}`;
1053
+ return `an array of objects, or {"path"}${spec.minItems ? `, at least ${spec.minItems}` : ""}${spec.maxItems ? `, at most ${spec.maxItems}` : ""}`;
1054
+ case "key":
1055
+ return `the name of a field in each row, like "month"`;
1056
+ case "numbers":
1057
+ return `an array of numbers, or {"path"}${spec.minItems ? `, at least ${spec.minItems}` : ""}`;
972
1058
  default:
973
1059
  return spec.kind;
974
1060
  }
@@ -49,6 +49,23 @@ const LABELS: RenderSurfaceLabels = {
49
49
  no: "No",
50
50
  };
51
51
 
52
+ /** What a component of your own is given to draw one instance of itself. */
53
+ export interface RenderDrawProps {
54
+ /** An id for its element, unique on the page. */
55
+ id: string;
56
+ /** The component as the agent sent it. Every prop has passed the check; one bound to the data model is still `{"path"}`, so read it. */
57
+ props: Readonly<Record<string, unknown>>;
58
+ /** A prop's value now: the literal, or what its binding holds. */
59
+ read: (value: unknown) => unknown;
60
+ /** Writes what a person entered to where a prop is bound. False for a prop that is not bound. */
61
+ write: (binding: unknown, value: unknown) => boolean;
62
+ /** The component's children, drawn. */
63
+ children: React.ReactNode;
64
+ }
65
+
66
+ /** Components for the types of a catalog that extends Mlola's, by type. */
67
+ export type RenderComponents = Readonly<Record<string, React.ComponentType<RenderDrawProps>>>;
68
+
52
69
  export interface RenderSurfaceProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onError"> {
53
70
  /**
54
71
  * A2UI messages for the Mlola Render catalog. Pass the same array with more
@@ -73,6 +90,8 @@ export interface RenderSurfaceProps extends Omit<React.HTMLAttributes<HTMLDivEle
73
90
  labels?: Partial<RenderSurfaceLabels>;
74
91
  /** The catalog to draw from. The Mlola Render catalog when left out. */
75
92
  rules?: RenderRules;
93
+ /** Draws the components a catalog adds to Mlola's (`rules`), each by its type. Mlola's own are always drawn by this component. */
94
+ components?: RenderComponents;
76
95
  /** Today's date for date fields, for server rendering. */
77
96
  today?: string;
78
97
  }
@@ -84,6 +103,7 @@ interface Drawing {
84
103
  base: string;
85
104
  labels: RenderSurfaceLabels;
86
105
  today?: string;
106
+ components?: RenderComponents;
87
107
  press: (componentId: string, scope: string) => void;
88
108
  }
89
109
 
@@ -102,7 +122,7 @@ const domId = (base: string, key: string) => `${base}-${Array.from(key, (charact
102
122
  * the surface's theme. Fields write to the surface's data model, and a
103
123
  * pressed Button answers with A2UI's action through `onAction`.
104
124
  */
105
- export function RenderSurface({ messages, streaming = false, host: givenHost, surfaceId, onAction, onError, theme = "surface", fallback = null, labels, rules = catalogRules, today, className, ...props }: RenderSurfaceProps) {
125
+ export function RenderSurface({ messages, streaming = false, host: givenHost, surfaceId, onAction, onError, theme = "surface", fallback = null, labels, rules = catalogRules, components, today, className, ...props }: RenderSurfaceProps) {
106
126
  // The first messages are drawn in the first render, so a surface rendered on the server arrives whole.
107
127
  const [ownHost] = React.useState(() => {
108
128
  const made = createRenderHost(rules);
@@ -151,7 +171,7 @@ export function RenderSurface({ messages, streaming = false, host: givenHost, su
151
171
  ? shown.map((id) => {
152
172
  const surface = host.surface(id);
153
173
  if (!surface) return null;
154
- const drawing: Drawing = { host, surfaceId: id, base: `${base}-${id}`, labels: words, today, press: press(id) };
174
+ const drawing: Drawing = { host, surfaceId: id, base: `${base}-${id}`, labels: words, today, components, press: press(id) };
155
175
  const themed = theme === "surface" ? surface.theme : null;
156
176
  return (
157
177
  // audit-allow: attr.reserved — the engine's own meaning: the surface is a region in the theme and mode its agent named
@@ -468,8 +488,16 @@ function Node({ drawing, instance }: { drawing: Drawing; instance: RenderInstanc
468
488
  </Table>
469
489
  );
470
490
  }
471
- default:
472
- return null;
491
+ default: {
492
+ // A type the catalog in `rules` adds: drawn by the component given for it, or not at all.
493
+ const Added = drawing.components && Object.hasOwn(drawing.components, node.type) ? drawing.components[node.type] : undefined;
494
+ if (!Added) return null;
495
+ return (
496
+ <Added id={fieldId} props={props} read={read} write={(binding, value) => host.write(surfaceId, binding, value, scope)}>
497
+ {inside}
498
+ </Added>
499
+ );
500
+ }
473
501
  }
474
502
  }
475
503
 
@@ -48,7 +48,7 @@
48
48
  "packages/components/progress/progress.tsx": "sha256-oDxj1Ccl1pKV4+e6A7aaJL1FThW8qs9r4Gb0hEmExeM=",
49
49
  "packages/components/radio-group/radio-group.tsx": "sha256-jhnSEBLkdpe8dv5us/Q1sMsQF4sKGRgDXWGKm2JSFew=",
50
50
  "packages/components/rating/rating.tsx": "sha256-prhfNzlCqLRWqURtq0MrX6MLdtuDpIOf5fA1Ie26L54=",
51
- "packages/components/render-surface/render-surface.tsx": "sha256-3L4CcYBHjwb1QyKGCygC4NEPL4TajSZiNcMzy4Uf0IM=",
51
+ "packages/components/render-surface/render-surface.tsx": "sha256-nW48kYLXoa3eeGZQ7gXMD/bTNH9MDeJlWyl1zzrvDwo=",
52
52
  "packages/components/resizable/resizable.tsx": "sha256-RFBpg/a2O82mmx+O5wT+um+y3y2w0rUMJJdBzRu9ONU=",
53
53
  "packages/components/segmented-control/segmented-control.tsx": "sha256-MCBG5XHFWCwbACctsYKbqe8mvSSR6pf60tyRgdCDKDA=",
54
54
  "packages/components/select/select.tsx": "sha256-7u1sshW6KAQgjgwQ1siN1P5GkBxboOiOJOdgLanForE=",
package/src/cli.js CHANGED
@@ -17,6 +17,7 @@ import { catalogStylesheets, clearCredentials, fetchProItems, GUIDE_FILENAME, ho
17
17
  import { serveMcp } from "./mcp.js";
18
18
  import { renderCatalog, renderRules } from "./knowledge.js";
19
19
  import { formatRenderReport, renderCatalogGuide, validateRender } from "../registry/render.js";
20
+ import { renderSystemPrompt } from "../registry/render-ai.js";
20
21
  import { pullTheme } from "./theme-pull.js";
21
22
  import { buildTheme } from "./theme-build.js";
22
23
 
@@ -38,6 +39,7 @@ Usage:
38
39
  mlola-ui theme build [--file mlola.theme.json]
39
40
  mlola-ui render check <file | -> [--json]
40
41
  mlola-ui render catalog [--schema]
42
+ mlola-ui render prompt [--tool] [--page-theme]
41
43
 
42
44
  Examples:
43
45
  npx mlola-ui init (also tells your coding agents about Mlola)
@@ -323,7 +325,12 @@ export async function run(argv, options = {}) {
323
325
  if (result.unchanged.length) {
324
326
  output.log(`• ${result.unchanged.length} file(s) already match the registry`);
325
327
  }
328
+ // The stylesheet from init imports the engine, so it is always needed.
329
+ const engines = new Map([[config.engine.engine ?? "@mlola-ui/engine", `^${CLI_VERSION}`], ...collectEngineDependencies(items)]);
326
330
  if (result.conflicts.length) {
331
+ // The files beside a refused one are written, and they need their packages now: a project
332
+ // left with new code on old packages does not build, and running this again would stop here again.
333
+ ensurePackages(cwd, engines, install);
327
334
  output.error(
328
335
  `✗ Refused to replace ${result.conflicts.length} modified file(s):\n ${result.conflicts.join(
329
336
  "\n ",
@@ -335,8 +342,6 @@ export async function run(argv, options = {}) {
335
342
  if (styleResult.index) output.log(`Pro styles are gathered in ${styleResult.index}; import it once, after the engine stylesheet.`);
336
343
  if (guideResult.status === "written") output.log(`Coding agents: point your AGENTS.md at ${GUIDE_FILENAME}, which lists every Pro class and attribute.`);
337
344
 
338
- // The stylesheet from init imports the engine, so it is always needed.
339
- const engines = new Map([[config.engine.engine ?? "@mlola-ui/engine", `^${CLI_VERSION}`], ...collectEngineDependencies(items)]);
340
345
  return ensurePackages(cwd, engines, install);
341
346
  }
342
347
 
@@ -373,7 +378,12 @@ export async function run(argv, options = {}) {
373
378
  output.log(hasFlag(args, "--schema") ? JSON.stringify(renderCatalog(), null, 2) : renderCatalogGuide(rules));
374
379
  return 0;
375
380
  }
376
- if (subcommand !== "check") throw new Error(`Unknown render command "${subcommand ?? ""}". Try: mlola-ui render check <file>, or mlola-ui render catalog`);
381
+ if (subcommand === "prompt") {
382
+ // The system prompt for a model someone calls themselves, for a server that is not JavaScript too.
383
+ output.log(renderSystemPrompt(rules, { output: hasFlag(args, "--tool") ? "tool" : "lines", theme: hasFlag(args, "--page-theme") ? "page" : "surface" }));
384
+ return 0;
385
+ }
386
+ if (subcommand !== "check") throw new Error(`Unknown render command "${subcommand ?? ""}". Try: mlola-ui render check <file>, mlola-ui render catalog, or mlola-ui render prompt`);
377
387
  if (!file) throw new Error("Name the file of A2UI messages to check, or - to read them from stdin: mlola-ui render check ui.json");
378
388
  const source = file === "-" ? (options.stdin ?? fs.readFileSync(0, "utf8")) : fs.readFileSync(path.resolve(cwd, file), "utf8");
379
389
  const result = validateRender(source, rules);