@sudajs/cli 0.2.1 → 0.4.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/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'crypto';
3
3
  import fs, { mkdir, writeFile, readFile, stat, readdir } from 'fs/promises';
4
+ import { createRequire } from 'module';
4
5
  import path2 from 'path';
5
- import { pathToFileURL } from 'url';
6
+ import { fileURLToPath, pathToFileURL } from 'url';
6
7
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
- import { createAgentPageSchemaOutput, createThemeAgentManifest, validateAgentPageContentWithManifest, agentValidationResultSchema, createPageDataFromAgentContent, findAgentSection, createAgentComponentSchemaOutput, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
9
+ import { createAgentPageSchemaOutput, checkThemeModule, formatThemeCheckResult, createThemeAgentManifest, validateAgentPageContentWithManifest, agentValidationResultSchema, createPageDataFromAgentContent, findAgentSection, createAgentComponentSchemaOutput, agentPageSchemaOutputSchema, agentComponentOutputSchema } from '@sudajs/theme-engine';
9
10
  import { extractLayoutChrome, ThemeRender } from '@sudajs/theme-engine/server';
10
11
  import { Command } from 'commander';
11
12
  import { build, context } from 'esbuild';
@@ -42,9 +43,42 @@ async function clearAuthConfig() {
42
43
  }
43
44
  }
44
45
  }
46
+ function protocolForHost(host) {
47
+ return host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https";
48
+ }
49
+ var CliAuthExpiredError = class extends Error {
50
+ code;
51
+ constructor(code, message) {
52
+ super(message);
53
+ this.name = "CliAuthExpiredError";
54
+ this.code = code;
55
+ }
56
+ };
57
+ async function cliAuthFetch(pathname, init = {}) {
58
+ const config = await readAuthConfig();
59
+ if (!config) {
60
+ throw new CliAuthExpiredError(
61
+ "unauthorized",
62
+ "Not logged in. Run `suda auth login` to authenticate first."
63
+ );
64
+ }
65
+ const baseUrl = `${protocolForHost(config.host)}://${config.host}`;
66
+ const headers = new Headers(init.headers);
67
+ headers.set("Authorization", `Bearer ${config.sessionToken}`);
68
+ const res = await fetch(`${baseUrl}${pathname}`, { ...init, headers });
69
+ if (res.status === 401) {
70
+ const body = await res.clone().json().catch(() => ({}));
71
+ const code = ["token_invalid", "token_revoked", "token_expired"].includes(body.error ?? "") ? body.error : "unauthorized";
72
+ await clearAuthConfig();
73
+ throw new CliAuthExpiredError(
74
+ code,
75
+ body.message ?? "Authentication is no longer valid. Run `suda auth login` to authenticate again."
76
+ );
77
+ }
78
+ return res;
79
+ }
45
80
  async function login(host = "app.sudayun.cn") {
46
- const protocol = host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https";
47
- const baseUrl = `${protocol}://${host}`;
81
+ const baseUrl = `${protocolForHost(host)}://${host}`;
48
82
  console.log(`Requesting device authorization from ${baseUrl}...`);
49
83
  const deviceRes = await fetch(`${baseUrl}/api/cli-auth/device`, {
50
84
  method: "POST"
@@ -96,30 +130,26 @@ async function status() {
96
130
  console.log("Not logged in. Run `suda auth login` to authenticate.");
97
131
  return;
98
132
  }
99
- const protocol = config.host.includes("localhost") || config.host.includes("127.0.0.1") ? "http" : "https";
100
133
  let res;
101
134
  try {
102
- res = await fetch(`${protocol}://${config.host}/api/auth/get-session`, {
103
- headers: {
104
- Authorization: `Bearer ${config.sessionToken}`
105
- }
106
- });
107
- } catch {
135
+ res = await cliAuthFetch("/api/cli-auth/whoami");
136
+ } catch (err) {
137
+ if (err instanceof CliAuthExpiredError) {
138
+ console.log(err.message);
139
+ return;
140
+ }
108
141
  console.error(`Failed to connect to ${config.host}.`);
109
142
  return;
110
143
  }
111
144
  if (!res.ok) {
112
- console.log("Session expired or invalid. Please run `suda auth login` again.");
113
- await clearAuthConfig();
114
- return;
115
- }
116
- const session = await res.json();
117
- if (!session?.user) {
118
- console.log("Session expired or invalid. Please run `suda auth login` again.");
119
- await clearAuthConfig();
145
+ console.error(
146
+ `Failed to verify session (${res.status} ${res.statusText}). Try again later.`
147
+ );
120
148
  return;
121
149
  }
122
- console.log(`Logged in as ${session.user.email || session.user.name} on ${config.host}`);
150
+ const data = await res.json();
151
+ const label = data.user?.email ?? data.user?.name ?? "your account";
152
+ console.log(`Logged in as ${label} on ${config.host}`);
123
153
  }
124
154
  async function logout() {
125
155
  const config = await readAuthConfig();
@@ -127,6 +157,17 @@ async function logout() {
127
157
  console.log("Not logged in.");
128
158
  return;
129
159
  }
160
+ try {
161
+ await cliAuthFetch("/api/cli-auth/revoke", { method: "POST" });
162
+ } catch (err) {
163
+ if (err instanceof CliAuthExpiredError) {
164
+ console.log("Logged out (token was already invalid).");
165
+ return;
166
+ }
167
+ console.warn(
168
+ `Could not contact ${config.host} to revoke token. The token will be cleared locally only.`
169
+ );
170
+ }
130
171
  await clearAuthConfig();
131
172
  console.log("Logged out successfully.");
132
173
  }
@@ -326,7 +367,7 @@ async function readJsonIfExists(filePath) {
326
367
  }
327
368
  return readJson(filePath);
328
369
  }
329
- function protocolForHost(host) {
370
+ function protocolForHost2(host) {
330
371
  return host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https";
331
372
  }
332
373
  async function requireCliBaseUrl() {
@@ -335,7 +376,7 @@ async function requireCliBaseUrl() {
335
376
  throw new Error("Not logged in. Run `suda auth login` to authenticate first.");
336
377
  }
337
378
  return {
338
- baseUrl: `${protocolForHost(config.host)}://${config.host}`,
379
+ baseUrl: `${protocolForHost2(config.host)}://${config.host}`,
339
380
  token: config.sessionToken
340
381
  };
341
382
  }
@@ -515,6 +556,16 @@ async function buildTheme(root, skipThemeBuild) {
515
556
  await writeThemeArtifacts(validated);
516
557
  return validateTheme(root);
517
558
  }
559
+ function runThemeCheck(theme) {
560
+ const result = checkThemeModule(theme.module);
561
+ const text = formatThemeCheckResult(result);
562
+ if (result.ok) {
563
+ console.log(text);
564
+ } else {
565
+ console.error(text);
566
+ }
567
+ return result.ok;
568
+ }
518
569
  async function watchTheme(root, skipThemeBuild) {
519
570
  if (!skipThemeBuild) {
520
571
  await runThemePackageBuild(root);
@@ -1162,8 +1213,16 @@ async function checksum(files) {
1162
1213
  }
1163
1214
  return hash.digest("hex");
1164
1215
  }
1165
- async function publishTheme(root, skipBuild) {
1216
+ async function publishTheme(root, skipBuild, skipCheck) {
1166
1217
  const theme = skipBuild ? await validateTheme(root) : await buildTheme(root, false);
1218
+ if (!skipCheck) {
1219
+ const ok = runThemeCheck(theme);
1220
+ if (!ok) {
1221
+ throw new Error(
1222
+ "AI metadata check failed. Fix the errors above or rerun with --skip-check."
1223
+ );
1224
+ }
1225
+ }
1167
1226
  const files = await collectThemeArtifactFiles(theme);
1168
1227
  if (!theme.clientEntryPath) {
1169
1228
  throw new Error("Missing dist/runtime.client.js. Run `suda theme build` first.");
@@ -1172,7 +1231,7 @@ async function publishTheme(root, skipBuild) {
1172
1231
  if (!config) {
1173
1232
  throw new Error("Not logged in. Run `suda auth login` to authenticate first.");
1174
1233
  }
1175
- const baseUrl = `${protocolForHost(config.host)}://${config.host}`;
1234
+ const baseUrl = `${protocolForHost2(config.host)}://${config.host}`;
1176
1235
  const { key, version } = theme.module.manifest;
1177
1236
  const intentRes = await fetch(`${baseUrl}/api/cli/themes/publish-intent`, {
1178
1237
  method: "POST",
@@ -1240,8 +1299,54 @@ async function publishTheme(root, skipBuild) {
1240
1299
  }
1241
1300
  console.log(`published ${key}@${version}`);
1242
1301
  }
1302
+ var cachedCliPackageVersions;
1303
+ async function readCliPackageVersions() {
1304
+ if (cachedCliPackageVersions) {
1305
+ return cachedCliPackageVersions;
1306
+ }
1307
+ const here = fileURLToPath(import.meta.url);
1308
+ const cliPkgPath = path2.resolve(path2.dirname(here), "..", "package.json");
1309
+ let cliRaw;
1310
+ try {
1311
+ cliRaw = await readFile(cliPkgPath, "utf8");
1312
+ } catch {
1313
+ cachedCliPackageVersions = { themeEngine: "*", cli: "*" };
1314
+ return cachedCliPackageVersions;
1315
+ }
1316
+ const cliPkg = JSON.parse(cliRaw);
1317
+ const cliVersion = typeof cliPkg.version === "string" ? cliPkg.version : "0.0.0";
1318
+ const declaredEngine = cliPkg.dependencies?.["@sudajs/theme-engine"];
1319
+ let themeEngine;
1320
+ if (declaredEngine && !declaredEngine.startsWith("workspace:")) {
1321
+ themeEngine = declaredEngine;
1322
+ } else {
1323
+ let resolved;
1324
+ try {
1325
+ const require2 = createRequire(import.meta.url);
1326
+ const enginePkgPath = require2.resolve("@sudajs/theme-engine/package.json");
1327
+ const engineRaw = await readFile(enginePkgPath, "utf8");
1328
+ const engineVersion = JSON.parse(engineRaw).version;
1329
+ if (typeof engineVersion === "string" && engineVersion.length > 0) {
1330
+ resolved = `^${engineVersion}`;
1331
+ }
1332
+ } catch {
1333
+ }
1334
+ if (!resolved) {
1335
+ throw new Error(
1336
+ "suda theme init: cannot determine the @sudajs/theme-engine version to scaffold. The CLI's package.json declares it as `workspace:*` (monorepo dev mode) but `@sudajs/theme-engine/package.json` could not be resolved from the CLI's runtime. Run `pnpm install` and re-run from the monorepo root, or install a published @sudajs/cli tarball (which pins a real semver range)."
1337
+ );
1338
+ }
1339
+ themeEngine = resolved;
1340
+ }
1341
+ cachedCliPackageVersions = {
1342
+ themeEngine,
1343
+ cli: `^${cliVersion}`
1344
+ };
1345
+ return cachedCliPackageVersions;
1346
+ }
1243
1347
  async function initTheme(target) {
1244
1348
  const key = path2.basename(target).replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase();
1349
+ const cliPackageVersions = await readCliPackageVersions();
1245
1350
  await mkdir(path2.join(target, "src"), { recursive: true });
1246
1351
  await writeFile(
1247
1352
  path2.join(target, "package.json"),
@@ -1267,11 +1372,11 @@ async function initTheme(target) {
1267
1372
  preview: "suda theme preview --theme-root ."
1268
1373
  },
1269
1374
  dependencies: {
1270
- "@sudajs/theme-engine": "^0.1.1"
1375
+ "@sudajs/theme-engine": cliPackageVersions.themeEngine
1271
1376
  },
1272
1377
  devDependencies: {
1273
1378
  "@puckeditor/core": "^0.21.2",
1274
- "@sudajs/cli": "^0.1.0",
1379
+ "@sudajs/cli": cliPackageVersions.cli,
1275
1380
  "@types/node": "^22.0.0",
1276
1381
  "@types/react": "^19.0.0",
1277
1382
  "@types/react-dom": "^19.0.0",
@@ -1354,10 +1459,17 @@ export const sourceManifest: ThemeSourceManifest = {
1354
1459
  );
1355
1460
  await writeFile(
1356
1461
  path2.join(target, "src", "sections.tsx"),
1357
- `import type { ComponentConfig } from "@puckeditor/core";
1462
+ `import type { SudaComponentConfig } from "@sudajs/theme-engine";
1358
1463
 
1359
- export const Hero: ComponentConfig = {
1464
+ export const Hero: SudaComponentConfig = {
1360
1465
  label: "Hero",
1466
+ ai: {
1467
+ instructions:
1468
+ "Primary page introduction used to communicate the main value proposition. " +
1469
+ "Place near the beginning of landing, product, service, or campaign pages. " +
1470
+ "Include one concise headline, supporting copy, and one primary call to action. " +
1471
+ "Use at most once per page. Do not use for ordinary section headings or article content.",
1472
+ },
1361
1473
  fields: {
1362
1474
  eyebrow: { type: "text", label: "Eyebrow" },
1363
1475
  title: { type: "text", label: "Title" },
@@ -1382,8 +1494,14 @@ export const Hero: ComponentConfig = {
1382
1494
  ),
1383
1495
  };
1384
1496
 
1385
- export const FeatureGrid: ComponentConfig = {
1497
+ export const FeatureGrid: SudaComponentConfig = {
1386
1498
  label: "Feature grid",
1499
+ ai: {
1500
+ instructions:
1501
+ "Section that lists 3\u20136 short feature or benefit cards explaining what the product or service offers. " +
1502
+ "Place after the hero / introduction and before the final call-to-action. " +
1503
+ "Use when the page needs to communicate multiple distinct value points; do not use for testimonials, FAQs, or step-by-step processes.",
1504
+ },
1387
1505
  fields: {
1388
1506
  title: { type: "text", label: "Title" },
1389
1507
  description: { type: "textarea", label: "Description" },
@@ -1421,8 +1539,14 @@ export const FeatureGrid: ComponentConfig = {
1421
1539
  ),
1422
1540
  };
1423
1541
 
1424
- export const Testimonial: ComponentConfig = {
1542
+ export const Testimonial: SudaComponentConfig = {
1425
1543
  label: "Testimonial",
1544
+ ai: {
1545
+ instructions:
1546
+ "Section that quotes a single customer or expert as social proof. " +
1547
+ "Place mid-page after a feature or value section, or near the end before the final CTA. " +
1548
+ "Do not invent quotes, names, or roles; only use content the user provides.",
1549
+ },
1426
1550
  fields: {
1427
1551
  quote: { type: "textarea", label: "Quote" },
1428
1552
  author: { type: "text", label: "Author" },
@@ -1441,8 +1565,14 @@ export const Testimonial: ComponentConfig = {
1441
1565
  ),
1442
1566
  };
1443
1567
 
1444
- export const CallToAction: ComponentConfig = {
1568
+ export const CallToAction: SudaComponentConfig = {
1445
1569
  label: "Call to action",
1570
+ ai: {
1571
+ instructions:
1572
+ "Closing conversion section that invites the visitor to take a specific action (sign up, contact, buy, etc.). " +
1573
+ "Place near the end of the page, after supporting content. " +
1574
+ "Use at most once per page. Do not use as the page's first introduction \u2014 the Hero component fills that role.",
1575
+ },
1446
1576
  fields: {
1447
1577
  title: { type: "text", label: "Title" },
1448
1578
  description: { type: "textarea", label: "Description" },
@@ -1470,7 +1600,8 @@ export const SECTION_COMPONENTS = { Hero, FeatureGrid, Testimonial, CallToAction
1470
1600
  await writeFile(
1471
1601
  path2.join(target, "src", "layout.tsx"),
1472
1602
  `import { getPageSlot } from "@sudajs/theme-engine/runtime";
1473
- import type { ComponentConfig, Config } from "@puckeditor/core";
1603
+ import type { Config } from "@puckeditor/core";
1604
+ import type { SudaComponentConfig } from "@sudajs/theme-engine";
1474
1605
  import type { ReactElement, ReactNode } from "react";
1475
1606
 
1476
1607
  type PuckExtras = { puck?: { metadata?: Record<string, unknown> } };
@@ -1485,22 +1616,40 @@ export const rootConfig: NonNullable<Config["root"]> = {
1485
1616
  ),
1486
1617
  };
1487
1618
 
1488
- export const Header: ComponentConfig = {
1619
+ export const Header: SudaComponentConfig = {
1489
1620
  label: "Header",
1621
+ ai: {
1622
+ instructions:
1623
+ "Site-wide header rendered at the top of every page. " +
1624
+ "Place once at the top of the layout (not inside page content). " +
1625
+ "Used for branding and primary navigation, not for promotional content.",
1626
+ },
1490
1627
  fields: { siteName: { type: "text", label: "Site name" } },
1491
1628
  defaultProps: { siteName: "${key}" },
1492
1629
  render: ({ siteName }) => <header className="${key}-header">{siteName}</header>,
1493
1630
  };
1494
1631
 
1495
- export const PageOutlet: ComponentConfig = {
1632
+ export const PageOutlet: SudaComponentConfig = {
1496
1633
  label: "Page outlet",
1634
+ ai: {
1635
+ exclude: true,
1636
+ instructions:
1637
+ "Structural slot where each page's content is injected. " +
1638
+ "Managed by the layout, never created or placed by AI.",
1639
+ },
1497
1640
  fields: {},
1498
1641
  defaultProps: {},
1499
1642
  render: (props: PuckExtras): ReactElement => <>{getPageSlot(props.puck?.metadata)}</>,
1500
1643
  };
1501
1644
 
1502
- export const Footer: ComponentConfig = {
1645
+ export const Footer: SudaComponentConfig = {
1503
1646
  label: "Footer",
1647
+ ai: {
1648
+ instructions:
1649
+ "Site-wide footer rendered at the bottom of every page. " +
1650
+ "Place once at the end of the layout. " +
1651
+ "Used for legal text, copyright, and secondary links \u2014 not for primary CTAs.",
1652
+ },
1504
1653
  fields: { text: { type: "text", label: "Text" } },
1505
1654
  defaultProps: { text: "\xA9 ${key}" },
1506
1655
  render: ({ text }) => <footer className="${key}-footer">{text}</footer>,
@@ -1720,14 +1869,110 @@ It is a **standalone** npm package \u2014 it does not depend on the SudaCloud mo
1720
1869
  - Prefer editing section props over changing render code when generating pages.
1721
1870
  - Do not edit files under \`dist/\`; they are generated.
1722
1871
 
1872
+ ## AI metadata (required)
1873
+
1874
+ Every component (and zone-level layout component) **must** declare \`ai.instructions\`.
1875
+ Without it the page-generation agent has no way to know what the component is for,
1876
+ and \`suda theme check\` (run automatically before publish) will fail.
1877
+
1878
+ ### Component-level \`ai\`
1879
+
1880
+ Each \`ComponentConfig\` must include:
1881
+
1882
+ \`\`\`ts
1883
+ import type { SudaComponentConfig } from "@sudajs/theme-engine";
1884
+
1885
+ export const Hero: SudaComponentConfig = {
1886
+ label: "Hero",
1887
+ ai: {
1888
+ instructions:
1889
+ "Primary page introduction used to communicate the main value proposition. " +
1890
+ "Place near the beginning of landing or product pages. " +
1891
+ "Include one primary CTA. Use at most once per page. " +
1892
+ "Do not use for ordinary section headings or article content.",
1893
+ },
1894
+ fields: { /* ... */ },
1895
+ defaultProps: { /* ... */ },
1896
+ render: (props) => /* ... */,
1897
+ };
1898
+ \`\`\`
1899
+
1900
+ A good \`instructions\` covers, in plain English:
1901
+
1902
+ 1. **Purpose** \u2014 what role this component plays on a page.
1903
+ 2. **Use when** \u2014 page types or scenarios where it should be selected.
1904
+ 3. **Avoid when** \u2014 situations that look similar but call for a different component.
1905
+ 4. **Placement** \u2014 where in the page it normally belongs (top, after features, near end, etc.).
1906
+ 5. **Frequency / composition** \u2014 how many times it can appear, what it must contain, and any adjacency rules.
1907
+
1908
+ Write executable, specific sentences (\`Use when...\`, \`Place after...\`, \`Use at most once per page.\`).
1909
+ Do **not** describe styling (colors, fonts, spacing) \u2014 those belong to the design system.
1910
+ Do **not** write vague filler like "modern", "engaging", "beautiful".
1911
+
1912
+ If two components look similar (e.g. Hero vs. PageHeader vs. SectionHeader),
1913
+ their \`instructions\` must explicitly differentiate them, otherwise the agent
1914
+ will mix them up.
1915
+
1916
+ ### Hiding a component from AI
1917
+
1918
+ Use \`ai.exclude: true\` for internal/debug components or anything the agent
1919
+ should never auto-create. Such components remain editable by humans:
1920
+
1921
+ \`\`\`ts
1922
+ ai: { exclude: true, instructions: "Internal debug block; AI must not create it." }
1923
+ \`\`\`
1924
+
1925
+ ### Field-level \`ai\` (recommended, optional)
1926
+
1927
+ Field-level metadata sharpens the generated content but is **not required**.
1928
+ Use it when a field's name and type alone don't fully convey the constraints:
1929
+
1930
+ \`\`\`ts
1931
+ fields: {
1932
+ title: {
1933
+ type: "text",
1934
+ ai: {
1935
+ instructions:
1936
+ "Main value-proposition headline. 4\u201312 words, focus on the visitor benefit, " +
1937
+ "do not repeat the eyebrow or description.",
1938
+ required: true,
1939
+ },
1940
+ },
1941
+ imageUrl: {
1942
+ type: "text",
1943
+ ai: {
1944
+ // Atomic values that cannot render correctly while half-streamed.
1945
+ stream: false,
1946
+ instructions: "Image that supports the section topic. Avoid logos, screenshots, or unrelated decoration.",
1947
+ },
1948
+ },
1949
+ }
1950
+ \`\`\`
1951
+
1952
+ Supported field-level keys: \`instructions\`, \`required\`, \`exclude\`, \`stream\`,
1953
+ \`bind\` (delegate to a tool), and \`schema\` (only needed for \`custom\` / \`external\` /
1954
+ \`user\` fields whose runtime shape can't be inferred).
1955
+
1956
+ ### Verifying
1957
+
1958
+ \`\`\`bash
1959
+ pnpm build # also runs the AI metadata check
1960
+ suda theme check # run the check on its own
1961
+ \`\`\`
1962
+
1963
+ Missing component-level \`ai.instructions\` is reported as an **error** and blocks
1964
+ the build. Very short or label-repeating instructions are reported as
1965
+ **warnings**.
1966
+
1723
1967
  ## Useful commands
1724
1968
 
1725
1969
  \`\`\`bash
1726
1970
  pnpm install
1727
1971
  pnpm typecheck
1728
- pnpm build # tsc + suda theme build --skip-theme-build
1972
+ pnpm build # tsc + suda theme build --skip-theme-build (runs AI check)
1729
1973
  pnpm validate
1730
1974
  pnpm preview
1975
+ suda theme check # AI metadata check only
1731
1976
  suda agent theme describe local --theme-root .
1732
1977
  suda agent section schema --theme local --section Hero --theme-root .
1733
1978
  suda agent page validate --theme local --input ./page.json --theme-root .
@@ -1799,9 +2044,29 @@ function buildProgram() {
1799
2044
  );
1800
2045
  }
1801
2046
  });
1802
- theme.command("build").description("Build server dist (via package script) and browser runtime.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-theme-build", "Skip the theme package build script.").action(async (options) => {
2047
+ theme.command("build").description("Build server dist (via package script) and browser runtime.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-theme-build", "Skip the theme package build script.").option(
2048
+ "--skip-check",
2049
+ "Skip the AI metadata check that normally runs after build."
2050
+ ).action(async (options) => {
1803
2051
  const result = await buildTheme(resolveThemeRoot(options), options.skipThemeBuild === true);
1804
2052
  console.log(`built ${result.module.manifest.key}@${result.module.manifest.version}`);
2053
+ if (options.skipCheck !== true) {
2054
+ const ok = runThemeCheck(result);
2055
+ if (!ok) {
2056
+ throw new Error(
2057
+ "AI metadata check failed. Fix the errors above or rerun with --skip-check."
2058
+ );
2059
+ }
2060
+ }
2061
+ });
2062
+ theme.command("check").description(
2063
+ "Run AI metadata checks on a built theme without rebuilding. Reports missing component-level ai.instructions as errors and weak content as warnings."
2064
+ ).option("--theme-root <path>", "Theme source/artifact root.").action(async (options) => {
2065
+ const theme2 = await validateTheme(resolveThemeRoot(options));
2066
+ const ok = runThemeCheck(theme2);
2067
+ if (!ok) {
2068
+ process.exitCode = 1;
2069
+ }
1805
2070
  });
1806
2071
  theme.command("dev").description("Watch and rebuild the browser runtime.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-theme-build", "Skip the theme package build script.").action(async (options) => {
1807
2072
  await watchTheme(resolveThemeRoot(options), options.skipThemeBuild === true);
@@ -1815,8 +2080,15 @@ function buildProgram() {
1815
2080
  ).option("--theme-root <path>", "Theme source/artifact root.").option("--output <path>", "Output PNG path relative to theme root.").option("--width <px>", "Viewport width in pixels.", "1280").option("--height <px>", "Viewport height in pixels.", "800").option("--port <port>", "Preview server port used during capture.", "4178").option("--skip-build", "Skip rebuilding the theme before capturing.").action(async (options) => {
1816
2081
  await screenshotTheme(resolveThemeRoot(options), options);
1817
2082
  });
1818
- theme.command("publish").description("Upload artifact to S3 and upsert ThemePackage/ThemeVersion.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-build", "Publish existing dist files without rebuilding.").action(async (options) => {
1819
- await publishTheme(resolveThemeRoot(options), options.skipBuild === true);
2083
+ theme.command("publish").description("Upload artifact to S3 and upsert ThemePackage/ThemeVersion.").option("--theme-root <path>", "Theme source/artifact root.").option("--skip-build", "Publish existing dist files without rebuilding.").option(
2084
+ "--skip-check",
2085
+ "Skip the AI metadata check that normally runs before upload."
2086
+ ).action(async (options) => {
2087
+ await publishTheme(
2088
+ resolveThemeRoot(options),
2089
+ options.skipBuild === true,
2090
+ options.skipCheck === true
2091
+ );
1820
2092
  });
1821
2093
  const agent = program.command("agent").description("Agent-friendly theme and page tooling.");
1822
2094
  const agentProject = agent.command("project").description("Manage Suda projects from the CLI.");