@utopia-studio-design/design-system-cli 0.7.2 → 0.8.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.
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  capabilityManifest, getBlock, getBlockDefinition, getBrandbookSkill, getComponent, getDoc, getLogoSkill, getMotionProfile, getRoomContext, getTemplate, getTheme,
4
4
  listBlockDefinitions, listBlocks, listComponents, listDocs, listLogoSkills, listMotionProfiles, listTemplates, listThemes, prepareLogoMakerRun,
5
- prepareBrandbookRun, repositoryDoctor, search, validateAgentMutation, validateLayout,
5
+ prepareBrandbookRun, repositoryDoctor, search, validateAgentMutation, validateBrandbook, validateLayout,
6
6
  } from '../lib/api.mjs'
7
7
 
8
8
  const tools = [
@@ -27,6 +27,7 @@ const tools = [
27
27
  ['prepare_logo_maker_run', 'Prepare a Brand Room-aware Logo Maker chat run using the same skill contract as Ceramic UI. This does not approve or publish the result.', { skillId: { type: 'string' }, brandName: { type: 'string' }, brief: { type: 'string' }, referenceIds: { type: 'array', items: { type: 'string' }, maxItems: 3 } }, (input) => prepareLogoMakerRun(input)],
28
28
  ['get_brandbook_skill', 'Read the Ceramic Brandbook workflow, GitHub source contract, provider rule, and Solution application rules.', {}, () => getBrandbookSkill()],
29
29
  ['prepare_brandbook_run', 'Prepare a Brand Room-aware Ceramic Brandbook run. Returns the required A/B/C direction phase and one-BRAND.md handoff prompt; it does not generate, save, or publish a release.', { brandName: { type: 'string' }, brief: { type: 'string', minLength: 12, maxLength: 4000 } }, (input) => prepareBrandbookRun(input)],
30
+ ['validate_brandbook', 'Mechanically validate the Ceramic BRAND.md contract, A/B/C refinement record, bilingual handoff, assets, and Solution showcase structure.', { path: { type: 'string' } }, (input) => validateBrandbook(input)],
30
31
  ['list_docs', 'List AI-readable design-system documents.', {}, () => listDocs()],
31
32
  ['get_docs', 'Read one design-system document.', { topic: { type: 'string' } }, ({ topic }) => getDoc(topic)],
32
33
  ['doctor', 'Validate the Ceramic source of truth.', {}, () => repositoryDoctor()],
@@ -30,6 +30,17 @@ Call `prepare_brandbook_run` with a brand name and useful brief. A logo is optio
30
30
  }
31
31
  ```
32
32
 
33
+ After the user selects and refines A, B, or C, write the single `BRAND.md` source and validate it before building or saving a release:
34
+
35
+ ```json
36
+ {
37
+ "name": "validate_brandbook",
38
+ "arguments": { "path": "/absolute/path/to/brand-directory" }
39
+ }
40
+ ```
41
+
42
+ Treat every validation error as blocking. A successful schema check does not replace visual, Arabic, licensing, or stakeholder review.
43
+
33
44
  This call prepares work; it does not generate, save, or publish a release.
34
45
 
35
46
  ## Present A, B, and C
@@ -76,4 +87,3 @@ Release checks:
76
87
  - Image provenance and rights metadata are present.
77
88
  - Marketing and Solution applications reflect the selected direction.
78
89
  - Brandbook validation passes before theme authoring begins.
79
-
package/lib/api.mjs CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  validateAgentLayoutMutation,
7
7
  validateLayoutDocument,
8
8
  } from './composition-runtime.mjs'
9
+ export { validateBrandbook } from './brandbook-validation.mjs'
9
10
 
10
11
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
11
12
  const workspaceRoot = resolve(packageRoot, '../..')
@@ -17,7 +18,7 @@ export const apiVersion = 1
17
18
  export const cliVersion = packageMetadata.version
18
19
  export const mcpLaunch = {
19
20
  command: 'npx',
20
- args: ['-y', '--package', '@utopia-studio-design/design-system-cli', 'utopia-ds', 'mcp'],
21
+ args: ['-y', '--package', `${packageMetadata.name}@${cliVersion}`, 'utopia-ds', 'mcp'],
21
22
  }
22
23
 
23
24
  export function paths(root = hasWorkspaceSource ? workspaceRoot : packagedDataRoot) {
@@ -274,7 +275,7 @@ export function capabilityManifest() {
274
275
  ],
275
276
  mcp: {
276
277
  ...mcpLaunch,
277
- tools: ['get_room_context', 'search', 'list_components', 'get_component', 'list_block_definitions', 'get_block_definition', 'validate_layout', 'validate_agent_layout_mutation', 'list_blocks', 'get_block', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_motion_profiles', 'get_motion_profile', 'list_logo_skills', 'get_logo_skill', 'prepare_logo_maker_run', 'get_brandbook_skill', 'prepare_brandbook_run', 'list_docs', 'get_docs', 'doctor'],
278
+ tools: ['get_room_context', 'search', 'list_components', 'get_component', 'list_block_definitions', 'get_block_definition', 'validate_layout', 'validate_agent_layout_mutation', 'list_blocks', 'get_block', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_motion_profiles', 'get_motion_profile', 'list_logo_skills', 'get_logo_skill', 'prepare_logo_maker_run', 'get_brandbook_skill', 'prepare_brandbook_run', 'validate_brandbook', 'list_docs', 'get_docs', 'doctor'],
278
279
  },
279
280
  }
280
281
  }
@@ -0,0 +1,112 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path'
3
+
4
+ const chapters = ['brand', 'core', 'photography', 'motion', 'marketing', 'solution']
5
+ const directionFields = ['name', 'thesis', 'typography', 'color', 'imagery', 'motion', 'marketing', 'solution', 'fit', 'tradeoff']
6
+ const text = (value) => typeof value === 'string' && value.trim().length > 0
7
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
8
+ const issue = (code, message, field) => ({ code, message, ...(field ? { field } : {}) })
9
+
10
+ function resolveBrandPath(inputPath, root) {
11
+ const candidate = isAbsolute(inputPath) ? inputPath : resolve(root, inputPath)
12
+ return extname(candidate).toLowerCase() === '.md' ? candidate : join(candidate, 'BRAND.md')
13
+ }
14
+
15
+ function parseJson(raw, label, errors) {
16
+ try {
17
+ return JSON.parse(raw)
18
+ } catch {
19
+ errors.push(issue('INVALID_JSON', `${label} must use valid JSON syntax.`))
20
+ return null
21
+ }
22
+ }
23
+
24
+ export function validateBrandbook({ path: inputPath }, root = process.cwd()) {
25
+ const errors = []
26
+ const warnings = []
27
+ const suppliedPath = String(inputPath || '').trim()
28
+ if (!suppliedPath) {
29
+ return { ok: false, path: null, errors: [issue('PATH_REQUIRED', 'Provide a BRAND.md file or its containing directory.')], warnings }
30
+ }
31
+
32
+ const brandPath = resolveBrandPath(suppliedPath, root)
33
+ if (!existsSync(brandPath)) {
34
+ return { ok: false, path: brandPath, errors: [issue('BRAND_MD_NOT_FOUND', `BRAND.md was not found at ${brandPath}.`)], warnings }
35
+ }
36
+
37
+ const raw = readFileSync(brandPath, 'utf8')
38
+ const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
39
+ if (!frontmatter) errors.push(issue('FRONTMATTER_REQUIRED', 'BRAND.md must start with JSON frontmatter between --- delimiters.'))
40
+ const meta = frontmatter ? parseJson(frontmatter[1], 'BRAND.md frontmatter', errors) : null
41
+ if (meta) {
42
+ if (meta.schema !== 'ceramic.document/v1' || meta.id !== 'brand') errors.push(issue('INVALID_DOCUMENT_IDENTITY', 'Frontmatter must use schema ceramic.document/v1 and id brand.'))
43
+ if (!/^[a-z][a-z0-9-]{0,79}$/.test(meta.brandId || '')) errors.push(issue('INVALID_BRAND_ID', 'brandId must be a lowercase slug.', 'brandId'))
44
+ if (!/^\d+\.\d+\.\d+$/.test(meta.version || '')) errors.push(issue('INVALID_VERSION', 'version must be semantic versioning.', 'version'))
45
+ if (!['draft', 'review', 'approved'].includes(meta.status)) errors.push(issue('INVALID_STATUS', 'status must be draft, review, or approved.', 'status'))
46
+ if (!Array.isArray(meta.requiredLocales) || !meta.requiredLocales.includes('en') || !meta.requiredLocales.includes('ar')) {
47
+ errors.push(issue('REQUIRED_LOCALES', 'requiredLocales must include en and ar unless the user explicitly approved another scope.', 'requiredLocales'))
48
+ }
49
+ }
50
+
51
+ const blocks = [...raw.matchAll(/^```ceramic\s*\r?\n([\s\S]*?)^```\s*$/gm)]
52
+ if (blocks.length !== 1) errors.push(issue('CERAMIC_CONTRACT_COUNT', 'BRAND.md must contain exactly one fenced ceramic JSON contract.'))
53
+ const contract = blocks.length === 1 ? parseJson(blocks[0][1], 'Ceramic contract', errors) : null
54
+ if (contract) {
55
+ for (const chapter of chapters) {
56
+ if (!object(contract[chapter])) {
57
+ errors.push(issue('CHAPTER_CONTRACT_REQUIRED', `Missing ${chapter} contract.`, chapter))
58
+ continue
59
+ }
60
+ const decisions = contract[chapter].decisions
61
+ if (!Array.isArray(decisions) || decisions.length === 0) errors.push(issue('DECISIONS_REQUIRED', `${chapter} needs at least one decision.`, `${chapter}.decisions`))
62
+ if (chapter !== 'brand') {
63
+ const expected = ['marketing', 'solution'].includes(chapter) ? 'core' : 'brand'
64
+ if (contract[chapter].extends !== expected) errors.push(issue('INVALID_INHERITANCE', `${chapter}.extends must be ${expected}.`, `${chapter}.extends`))
65
+ }
66
+ }
67
+
68
+ const session = contract.brand?.directionSession
69
+ if (!object(session) || !Array.isArray(session.options) || session.options.length !== 3) {
70
+ errors.push(issue('DIRECTION_SESSION_REQUIRED', 'brand.directionSession must contain exactly three options.', 'brand.directionSession'))
71
+ } else {
72
+ if (session.options.map((option) => option?.id).join('') !== 'ABC') errors.push(issue('DIRECTION_ORDER', 'Direction options must be ordered A, B, C.', 'brand.directionSession.options'))
73
+ for (const option of session.options) {
74
+ if (!object(option) || !directionFields.every((field) => text(option[field]))) errors.push(issue('DIRECTION_INCOMPLETE', `Direction ${option?.id || '?'} is incomplete.`, 'brand.directionSession.options'))
75
+ }
76
+ if (!['A', 'B', 'C'].includes(session.selected)) errors.push(issue('DIRECTION_SELECTION_REQUIRED', 'A selected direction is required.', 'brand.directionSession.selected'))
77
+ if (!['refined', 'approved'].includes(session.status) || !text(session.refinement)) errors.push(issue('DIRECTION_REFINEMENT_REQUIRED', 'The selected direction must include a refinement note and refined or approved status.', 'brand.directionSession'))
78
+ }
79
+
80
+ const arabic = contract.brand?.localization?.ar
81
+ if (!object(arabic) || !text(arabic.displayFont) || !text(arabic.bodyFont) || !object(arabic.text) || Object.keys(arabic.text).length === 0) {
82
+ errors.push(issue('ARABIC_PARITY_REQUIRED', 'brand.localization.ar must provide displayFont, bodyFont, and a nonempty 1:1 text map.', 'brand.localization.ar'))
83
+ }
84
+
85
+ const showcases = contract.solution?.showcases
86
+ if (!Array.isArray(showcases) || showcases.length < 1 || showcases.length > 3) errors.push(issue('SOLUTION_SHOWCASES', 'solution.showcases must contain one to three brand-fit applications.', 'solution.showcases'))
87
+ else if (new Set(showcases.map((showcase) => showcase?.type)).size !== showcases.length || showcases.some((showcase) => !['phone', 'desktop', 'environment'].includes(showcase?.type))) {
88
+ errors.push(issue('SOLUTION_SHOWCASE_TYPES', 'Solution showcase types must be unique phone, desktop, or environment entries.', 'solution.showcases'))
89
+ }
90
+ }
91
+
92
+ const anchors = [...raw.matchAll(/^## .+ \{#([a-z]+)\}\s*$/gm)].map((match) => match[1])
93
+ for (const chapter of chapters) if (!anchors.includes(chapter)) errors.push(issue('CHAPTER_ANCHOR_REQUIRED', `Missing {#${chapter}} chapter anchor.`, chapter))
94
+
95
+ const manifestPath = join(dirname(brandPath), 'assets', 'manifest.json')
96
+ if (!existsSync(manifestPath)) errors.push(issue('ASSET_MANIFEST_REQUIRED', 'assets/manifest.json is required.'))
97
+ else {
98
+ const manifest = parseJson(readFileSync(manifestPath, 'utf8'), 'Asset manifest', errors)
99
+ if (manifest && (manifest.schema !== 'ceramic.assets/v1' || !Array.isArray(manifest.assets))) errors.push(issue('INVALID_ASSET_MANIFEST', 'Asset manifest must use ceramic.assets/v1 and contain an assets array.'))
100
+ if (manifest?.assets) {
101
+ const families = new Set(manifest.assets.filter((asset) => asset.kind === 'font').map((asset) => asset.family))
102
+ const arabic = contract?.brand?.localization?.ar
103
+ for (const role of ['displayFont', 'bodyFont']) if (text(arabic?.[role]) && !families.has(arabic[role])) errors.push(issue('ARABIC_FONT_NOT_BUNDLED', `Arabic ${role} ${arabic[role]} is not registered as a local font asset.`, `brand.localization.ar.${role}`))
104
+ for (const asset of manifest.assets) {
105
+ if (!text(asset.path) || !asset.path.startsWith('assets/') || asset.path.includes('..') || !existsSync(join(dirname(brandPath), asset.path))) errors.push(issue('ASSET_FILE_MISSING', `Manifest asset is missing or unsafe: ${asset.path || '(no path)'}.`, 'assets'))
106
+ }
107
+ }
108
+ }
109
+
110
+ if (meta?.status === 'approved') warnings.push(issue('APPROVAL_SCOPE', 'Schema validation does not replace visual, linguistic, licensing, or stakeholder approval.'))
111
+ return { ok: errors.length === 0, path: brandPath, errors, warnings, summary: { errorCount: errors.length, warningCount: warnings.length, chapters: anchors.filter((anchor) => chapters.includes(anchor)).length } }
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utopia-studio-design/design-system-cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "AI-readable CLI and MCP server for Ceramic Design System",
5
5
  "ceramic": {
6
6
  "designSystem": "^0.10.0"