@enfyra/mcp-server 0.1.42 → 0.1.44

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.
@@ -22,7 +22,7 @@ export function buildMcpServerInstructions(apiBaseUrl, options = {}) {
22
22
  '- For target sanity checks, call `get_enfyra_api_context`; do not load broad metadata only to confirm the API base.',
23
23
  '- When the goal is clear but the tool path is not, call `discover_enfyra_workflows` with intent/risk/surface. Use `detail: "plan"` before writes, follow `primaryPath`, and treat `avoidTools` as hard negative-routing boundaries.',
24
24
  '- Discover before deciding, then inspect narrowly. Use `inspect_table`, `inspect_route`, `inspect_feature`, `search_admin_extensions`, or `search_runtime_zone` for the exact table/route/feature/runtime artifact. Run broad discovery tools sequentially, not in parallel.',
25
- '- Load examples only when needed with `get_enfyra_examples`. For OAuth use `get_enfyra_examples({ category: "oauth-setup" })`. For extension UI, call `get_extension_theme_contract` before writing or reviewing UI and `get_theme_class_reference` only when exact classes are needed.',
25
+ '- Load examples only when needed with `get_enfyra_examples`. For OAuth use `get_enfyra_examples({ category: "oauth-setup" })`. For extension UI, call `get_extension_theme_contract` before writing or reviewing UI, use `build_extension_*` tools for high-contract components such as drawers, modals, page shell headers/actions, permission gates, empty states, and resource lists, and call `get_theme_class_reference` only when exact classes are needed.',
26
26
  '- Prefer the most specific business operation tool over raw metadata CRUD.',
27
27
  '- Before any write, call `get_enfyra_required_knowledge({ scope })` with the right scope and pass returned ack keys into write tools. Choose scope by task domain:',
28
28
  ' • `schema` — table, column, relation, record CRUD, route permission, guard, field permission, column rule, cache reload, GraphQL enablement, log debug.',
@@ -1 +1 @@
1
- {"version":3,"file":"mcp-instructions.js","sourceRoot":"","sources":["../../src/lib/mcp-instructions.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,UAAU;IACzC,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,OAAO;QACL,cAAc,EAAE,GAAG,IAAI,UAAU;QACjC,gBAAgB,EAAE,GAAG,IAAI,iBAAiB;KAC3C,CAAC;AACJ,CAAC;AAMD,MAAM,UAAU,0BAA0B,CAAC,UAAU,EAAE,UAAiC,EAAE;IACxF,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,MAAM,EAAE,cAAc,EAAE,gBAAgB,EAAE,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;IAC1E,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,IAAI,CAAC;IAEvD,OAAO;QACL,eAAe;QACf,EAAE;QACF,gCAAgC,IAAI,KAAK;QACzC,wBAAwB,cAAc,YAAY,gBAAgB,KAAK;QACvE,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/C,EAAE;QACF,qBAAqB;QACrB,qJAAqJ;QACrJ,qHAAqH;QACrH,qOAAqO;QACrO,8QAA8Q;QAC9Q,yRAAyR;QACzR,4EAA4E;QAC5E,oKAAoK;QACpK,2JAA2J;QAC3J,uJAAuJ;QACvJ,8IAA8I;QAC9I,yDAAyD;QACzD,4EAA4E;QAC5E,sMAAsM;QACtM,mLAAmL;QACnL,8LAA8L;QAC9L,EAAE;QACF,0BAA0B;QAC1B,uHAAuH;QACvH,sLAAsL;QACtL,gLAAgL;QAChL,iLAAiL;QACjL,yLAAyL;QACzL,kKAAkK;QAClK,0HAA0H;QAC1H,4LAA4L;QAC5L,EAAE;QACF,+BAA+B;QAC/B,4HAA4H;QAC5H,4GAA4G;QAC5G,mIAAmI;QACnI,kJAAkJ;QAClJ,oNAAoN;QACpN,iKAAiK;QACjK,gIAAgI;QAChI,EAAE;QACF,+BAA+B;QAC/B,iIAAiI;QACjI,uJAAuJ;QACvJ,yOAAyO;QACzO,+JAA+J;QAC/J,iMAAiM;QACjM,wSAAwS;QACxS,qLAAqL;QACrL,kPAAkP;QAClP,+PAA+P;QAC/P,EAAE;QACF,4BAA4B;QAC5B,0MAA0M;QAC1M,oHAAoH;QACpH,kIAAkI;QAClI,8MAA8M;QAC9M,kNAAkN;QAClN,wLAAwL;QACxL,EAAE;QACF,uIAAuI;KACxI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"mcp-instructions.js","sourceRoot":"","sources":["../../src/lib/mcp-instructions.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,MAAM,UAAU,gBAAgB,CAAC,UAAU;IACzC,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,OAAO;QACL,cAAc,EAAE,GAAG,IAAI,UAAU;QACjC,gBAAgB,EAAE,GAAG,IAAI,iBAAiB;KAC3C,CAAC;AACJ,CAAC;AAMD,MAAM,UAAU,0BAA0B,CAAC,UAAU,EAAE,UAAiC,EAAE;IACxF,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACzD,MAAM,EAAE,cAAc,EAAE,gBAAgB,EAAE,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;IAC1E,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,IAAI,CAAC;IAEvD,OAAO;QACL,eAAe;QACf,EAAE;QACF,gCAAgC,IAAI,KAAK;QACzC,wBAAwB,cAAc,YAAY,gBAAgB,KAAK;QACvE,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/C,EAAE;QACF,qBAAqB;QACrB,qJAAqJ;QACrJ,qHAAqH;QACrH,qOAAqO;QACrO,8QAA8Q;QAC9Q,mcAAmc;QACnc,4EAA4E;QAC5E,oKAAoK;QACpK,2JAA2J;QAC3J,uJAAuJ;QACvJ,8IAA8I;QAC9I,yDAAyD;QACzD,4EAA4E;QAC5E,sMAAsM;QACtM,mLAAmL;QACnL,8LAA8L;QAC9L,EAAE;QACF,0BAA0B;QAC1B,uHAAuH;QACvH,sLAAsL;QACtL,gLAAgL;QAChL,iLAAiL;QACjL,yLAAyL;QACzL,kKAAkK;QAClK,0HAA0H;QAC1H,4LAA4L;QAC5L,EAAE;QACF,+BAA+B;QAC/B,4HAA4H;QAC5H,4GAA4G;QAC5G,mIAAmI;QACnI,kJAAkJ;QAClJ,oNAAoN;QACpN,iKAAiK;QACjK,gIAAgI;QAChI,EAAE;QACF,+BAA+B;QAC/B,iIAAiI;QACjI,uJAAuJ;QACvJ,yOAAyO;QACzO,+JAA+J;QAC/J,iMAAiM;QACjM,wSAAwS;QACxS,qLAAqL;QACrL,kPAAkP;QAClP,+PAA+P;QAC/P,EAAE;QACF,4BAA4B;QAC5B,0MAA0M;QAC1M,oHAAoH;QACpH,kIAAkI;QAClI,8MAA8M;QAC9M,kNAAkN;QAClN,wLAAwL;QACxL,EAAE;QACF,uIAAuI;KACxI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
@@ -1,10 +1,65 @@
1
+ export declare function buildExtensionDrawerSnippet(input: any): {
2
+ action: string;
3
+ component: string;
4
+ snippet: string;
5
+ normalizedBodyChanges: string[];
6
+ warnings: string[];
7
+ contract: string[];
8
+ };
9
+ export declare function buildExtensionModalSnippet(input: any): {
10
+ action: string;
11
+ component: string;
12
+ snippet: string;
13
+ normalizedBodyChanges: string[];
14
+ warnings: string[];
15
+ contract: string[];
16
+ };
17
+ export declare function buildExtensionPageShellSnippet(input: any): {
18
+ action: string;
19
+ snippet: string;
20
+ contract: string[];
21
+ };
22
+ export declare function buildExtensionPermissionGateSnippet(input: any): {
23
+ action: string;
24
+ component: string;
25
+ snippet: string;
26
+ normalizedBodyChanges: string[];
27
+ warnings: string[];
28
+ contract: string[];
29
+ };
30
+ export declare function buildExtensionEmptyStateSnippet(input: any): {
31
+ action: string;
32
+ component: string;
33
+ snippet: string;
34
+ contract: string[];
35
+ };
36
+ export declare function buildExtensionResourceListSnippet(input: any): {
37
+ action: string;
38
+ components: string[];
39
+ snippet: string;
40
+ contract: string[];
41
+ };
42
+ export declare function reviewExtensionUiContract(code: any): {
43
+ action: string;
44
+ valid: boolean;
45
+ issueCount: number;
46
+ issues: {
47
+ severity: "error" | "warning";
48
+ rule: string;
49
+ message: string;
50
+ suggestion: string;
51
+ }[];
52
+ nextSteps: string[];
53
+ };
1
54
  export declare function validateExtensionCodeLocally(code: any): {
2
55
  componentCasing: string;
56
+ fieldWidth: string;
3
57
  };
4
58
  export declare function validateExtensionCode(apiUrl: any, code: any, name: any): Promise<{
5
59
  valid: boolean;
6
60
  localChecks: {
7
61
  componentCasing: string;
62
+ fieldWidth: string;
8
63
  };
9
64
  extensionId: any;
10
65
  compiledLength: any;
@@ -19,6 +19,11 @@ const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
19
19
  'UFormField',
20
20
  'UIcon',
21
21
  'UInput',
22
+ 'UInputMenu',
23
+ 'UInputNumber',
24
+ 'UInputTags',
25
+ 'UInputTime',
26
+ 'UInputDate',
22
27
  'UModal',
23
28
  'USelect',
24
29
  'USelectMenu',
@@ -30,6 +35,18 @@ const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
30
35
  'Widget',
31
36
  ];
32
37
  const AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE = new Map(AUTO_INJECTED_EXTENSION_COMPONENT_TAGS.map((tag) => [tag.toLowerCase(), tag]));
38
+ const FULL_WIDTH_EXTENSION_FIELD_TAGS = [
39
+ 'UInput',
40
+ 'UTextarea',
41
+ 'USelect',
42
+ 'USelectMenu',
43
+ 'UInputMenu',
44
+ 'UInputNumber',
45
+ 'UInputTags',
46
+ 'UInputTime',
47
+ 'UInputDate',
48
+ ];
49
+ const FULL_WIDTH_EXTENSION_FIELD_PATTERN = new RegExp(`<(${FULL_WIDTH_EXTENSION_FIELD_TAGS.join('|')})(\\s[^<>]*?)(\\/?)>`, 'g');
33
50
  function unwrapData(result) {
34
51
  return Array.isArray(result?.data) ? result.data : [];
35
52
  }
@@ -299,6 +316,378 @@ async function findHandler(apiUrl, routeId, methodId) {
299
316
  function jsonText(payload) {
300
317
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
301
318
  }
319
+ function escapeSingleQuoted(value) {
320
+ return String(value ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
321
+ }
322
+ function quoteJsString(value) {
323
+ return `'${escapeSingleQuoted(value)}'`;
324
+ }
325
+ function normalizeVueBodySnippet(body) {
326
+ let code = String(body || '').trim();
327
+ const changes = [];
328
+ code = code.replace(FULL_WIDTH_EXTENSION_FIELD_PATTERN, (full, tag, attrs, slash) => {
329
+ if (/\bdata-compact\b/.test(attrs) || /\bdata-inline\b/.test(attrs))
330
+ return full;
331
+ if (/\bclass=/.test(attrs)) {
332
+ const nextAttrs = attrs.replace(/class="([^"]*)"/, (classMatch, classes) => {
333
+ if (String(classes).split(/\s+/).includes('w-full'))
334
+ return classMatch;
335
+ changes.push(`Added w-full to ${tag}.`);
336
+ return `class="${classes} w-full"`;
337
+ });
338
+ if (nextAttrs !== attrs)
339
+ return `<${tag}${nextAttrs}${slash}>`;
340
+ return full;
341
+ }
342
+ changes.push(`Added class="w-full" to ${tag}.`);
343
+ return `<${tag} class="w-full"${attrs}${slash}>`;
344
+ });
345
+ code = code.replace(/<button(\s[^>]*)?>/g, (full, attrs = '') => {
346
+ if (/\btype=/.test(attrs))
347
+ return full;
348
+ changes.push('Added type="button" to a native button.');
349
+ return `<button type="button"${attrs}>`;
350
+ });
351
+ return { code, changes: Array.from(new Set(changes)) };
352
+ }
353
+ function findMissingFullWidthFieldControls(code) {
354
+ const violations = [];
355
+ for (const template of readTemplateBlocks(code)) {
356
+ let match;
357
+ FULL_WIDTH_EXTENSION_FIELD_PATTERN.lastIndex = 0;
358
+ while ((match = FULL_WIDTH_EXTENSION_FIELD_PATTERN.exec(template))) {
359
+ const [snippet, tag, attrs] = match;
360
+ if (/\bdata-compact\b/.test(attrs) || /\bdata-inline\b/.test(attrs))
361
+ continue;
362
+ const classMatch = attrs.match(/\bclass="([^"]*)"/);
363
+ if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
364
+ violations.push({ tag, snippet: snippet.length > 160 ? `${snippet.slice(0, 157)}...` : snippet });
365
+ }
366
+ }
367
+ }
368
+ return violations;
369
+ }
370
+ function indentLines(code, spaces = 2) {
371
+ const pad = ' '.repeat(spaces);
372
+ return String(code || '')
373
+ .split('\n')
374
+ .map((line) => (line.trim() ? `${pad}${line}` : line))
375
+ .join('\n');
376
+ }
377
+ function buildFooterActionObject(action) {
378
+ if (!action)
379
+ return null;
380
+ if (typeof action === 'string')
381
+ return action;
382
+ const entries = [];
383
+ if (action.labelExpression)
384
+ entries.push(`label: ${action.labelExpression}`);
385
+ else if (action.label)
386
+ entries.push(`label: ${quoteJsString(action.label)}`);
387
+ if (action.icon)
388
+ entries.push(`icon: ${quoteJsString(action.icon)}`);
389
+ if (action.loading)
390
+ entries.push(`loading: ${action.loading}`);
391
+ if (action.disabled)
392
+ entries.push(`disabled: ${action.disabled}`);
393
+ if (action.color)
394
+ entries.push(`color: ${quoteJsString(action.color)}`);
395
+ if (action.variant)
396
+ entries.push(`variant: ${quoteJsString(action.variant)}`);
397
+ if (action.tone)
398
+ entries.push(`tone: ${quoteJsString(action.tone)}`);
399
+ if (action.onClick)
400
+ entries.push(`onClick: ${action.onClick}`);
401
+ return `{ ${entries.join(', ')} }`;
402
+ }
403
+ function titleMarkup(title, titleExpression) {
404
+ if (titleExpression)
405
+ return `{{ ${titleExpression} }}`;
406
+ return String(title || 'Untitled');
407
+ }
408
+ export function buildExtensionDrawerSnippet(input) {
409
+ const normalized = normalizeVueBodySnippet(input.body || '');
410
+ const titleContent = titleMarkup(input.title, input.titleExpression);
411
+ const model = input.model || 'drawerOpen';
412
+ const attrs = [
413
+ `v-model="${model}"`,
414
+ `direction="${input.direction || 'right'}"`,
415
+ ];
416
+ if (input.nested)
417
+ attrs.push('nested');
418
+ if (input.handleOnly)
419
+ attrs.push('handle-only');
420
+ const cancelAction = input.cancelAction === false
421
+ ? null
422
+ : buildFooterActionObject(input.cancelAction || { label: 'Cancel', onClick: `() => (${model} = false)` });
423
+ const primaryAction = buildFooterActionObject(input.primaryAction);
424
+ const dangerAction = buildFooterActionObject(input.dangerAction);
425
+ if (cancelAction)
426
+ attrs.push(`:cancel-action="${cancelAction}"`);
427
+ if (primaryAction)
428
+ attrs.push(`:primary-action="${primaryAction}"`);
429
+ if (dangerAction)
430
+ attrs.push(`:danger-action="${dangerAction}"`);
431
+ if (input.footerHint)
432
+ attrs.push(`footer-hint="${escapeSingleQuoted(input.footerHint).replace(/"/g, '&quot;')}"`);
433
+ const snippet = [
434
+ '<CommonDrawer',
435
+ ...attrs.map((attr) => ` ${attr}`),
436
+ '>',
437
+ ' <template #header>',
438
+ ` <h2 class="text-lg font-semibold eapp-text-primary">${titleContent}</h2>`,
439
+ ' </template>',
440
+ '',
441
+ ' <template #body>',
442
+ indentLines(normalized.code, 4),
443
+ ' </template>',
444
+ '</CommonDrawer>',
445
+ ].join('\n');
446
+ const warnings = [];
447
+ if (!primaryAction)
448
+ warnings.push('Editing/create drawers usually need primaryAction for Save/Create.');
449
+ return {
450
+ action: 'extension_drawer_built',
451
+ component: 'CommonDrawer',
452
+ snippet,
453
+ normalizedBodyChanges: normalized.changes,
454
+ warnings,
455
+ contract: [
456
+ 'Use #header and #body slots; do not use a title prop.',
457
+ 'Use primaryAction for Save/Create and dangerAction for destructive edit actions.',
458
+ 'Body form controls are normalized to class="w-full" unless intentionally compact.',
459
+ 'Native buttons in the body are normalized to type="button".',
460
+ ],
461
+ };
462
+ }
463
+ export function buildExtensionModalSnippet(input) {
464
+ const normalized = normalizeVueBodySnippet(input.body || '');
465
+ const titleContent = titleMarkup(input.title, input.titleExpression);
466
+ const model = input.model || 'modalOpen';
467
+ const tag = input.alias === 'UModal' ? 'UModal' : 'CommonModal';
468
+ const attrs = [`v-model:open="${model}"`];
469
+ const cancelAction = input.cancelAction === false
470
+ ? null
471
+ : buildFooterActionObject(input.cancelAction || { label: 'Cancel', onClick: `() => (${model} = false)` });
472
+ const primaryAction = buildFooterActionObject(input.primaryAction);
473
+ const dangerAction = buildFooterActionObject(input.dangerAction);
474
+ if (cancelAction)
475
+ attrs.push(`:cancel-action="${cancelAction}"`);
476
+ if (primaryAction)
477
+ attrs.push(`:primary-action="${primaryAction}"`);
478
+ if (dangerAction)
479
+ attrs.push(`:danger-action="${dangerAction}"`);
480
+ if (input.footerHint)
481
+ attrs.push(`footer-hint="${escapeSingleQuoted(input.footerHint).replace(/"/g, '&quot;')}"`);
482
+ const snippet = [
483
+ `<${tag}`,
484
+ ...attrs.map((attr) => ` ${attr}`),
485
+ '>',
486
+ ' <template #header>',
487
+ ` <h2 class="text-lg font-semibold eapp-text-primary">${titleContent}</h2>`,
488
+ ' </template>',
489
+ '',
490
+ ' <template #body>',
491
+ indentLines(normalized.code, 4),
492
+ ' </template>',
493
+ `</${tag}>`,
494
+ ].join('\n');
495
+ const warnings = [];
496
+ if (!primaryAction && !dangerAction)
497
+ warnings.push('Mutation or confirmation modals usually need primaryAction or dangerAction for the final action.');
498
+ return {
499
+ action: 'extension_modal_built',
500
+ component: tag,
501
+ snippet,
502
+ normalizedBodyChanges: normalized.changes,
503
+ warnings,
504
+ contract: [
505
+ 'Use v-model:open and #header/#body slots; do not use a title prop.',
506
+ 'Use primaryAction for ordinary final actions and dangerAction for destructive confirmation.',
507
+ 'Body form controls are normalized to class="w-full" unless intentionally compact.',
508
+ 'Native buttons in the body are normalized to type="button".',
509
+ ],
510
+ };
511
+ }
512
+ function jsObjectLiteral(entries) {
513
+ return `{ ${entries.filter(Boolean).join(', ')} }`;
514
+ }
515
+ function buildHeaderActionLiteral(action) {
516
+ const entries = [
517
+ action.id ? `id: ${quoteJsString(action.id)}` : null,
518
+ action.label ? `label: ${quoteJsString(action.label)}` : null,
519
+ action.icon ? `icon: ${quoteJsString(action.icon)}` : null,
520
+ `color: ${quoteJsString(action.color || 'neutral')}`,
521
+ `variant: ${quoteJsString(action.variant || 'outline')}`,
522
+ action.loading ? `loading: ${action.loading}` : null,
523
+ action.disabled ? `disabled: ${action.disabled}` : null,
524
+ action.to ? `to: ${quoteJsString(action.to)}` : null,
525
+ action.onClick ? `onClick: ${action.onClick}` : null,
526
+ typeof action.order === 'number' ? `order: ${action.order}` : null,
527
+ action.side ? `side: ${quoteJsString(action.side)}` : null,
528
+ ];
529
+ return jsObjectLiteral(entries);
530
+ }
531
+ export function buildExtensionPageShellSnippet(input) {
532
+ const title = input.titleExpression || quoteJsString(input.title || 'Untitled');
533
+ const headerEntries = [
534
+ `title: ${title}`,
535
+ input.description ? `description: ${quoteJsString(input.description)}` : null,
536
+ input.leadingIcon ? `leadingIcon: ${quoteJsString(input.leadingIcon)}` : null,
537
+ `gradient: ${quoteJsString(input.gradient || 'none')}`,
538
+ `variant: ${quoteJsString(input.variant || 'minimal')}`,
539
+ ];
540
+ const actions = Array.isArray(input.headerActions) ? input.headerActions : [];
541
+ const lines = [
542
+ 'const { registerPageHeader } = usePageHeaderRegistry();',
543
+ `registerPageHeader(${jsObjectLiteral(headerEntries)});`,
544
+ ];
545
+ if (actions.length) {
546
+ lines.push('const { register: registerHeaderActions } = useHeaderActionRegistry();');
547
+ lines.push(`registerHeaderActions([\n${actions.map((action) => ` ${buildHeaderActionLiteral(action)}`).join(',\n')}\n]);`);
548
+ }
549
+ return {
550
+ action: 'extension_page_shell_built',
551
+ snippet: lines.join('\n'),
552
+ contract: [
553
+ 'Use usePageHeaderRegistry so the app shell renders the page header.',
554
+ 'Use useHeaderActionRegistry for toolbar actions instead of rendering duplicate page headers or local top bars.',
555
+ 'Use primary solid only for the main scope action; secondary actions default to neutral outline.',
556
+ ],
557
+ };
558
+ }
559
+ export function buildExtensionPermissionGateSnippet(input) {
560
+ const normalized = normalizeVueBodySnippet(input.body || '<slot />');
561
+ let condition;
562
+ if (input.condition) {
563
+ condition = input.condition;
564
+ }
565
+ else if (input.route) {
566
+ const methods = Array.isArray(input.methods) && input.methods.length ? input.methods : ['GET'];
567
+ condition = `{ or: [{ route: ${quoteJsString(input.route)}, methods: [${methods.map(quoteJsString).join(', ')}] }] }`;
568
+ }
569
+ else {
570
+ condition = 'null';
571
+ }
572
+ const snippet = [
573
+ `<PermissionGate :condition="${condition}">`,
574
+ indentLines(normalized.code, 2),
575
+ '</PermissionGate>',
576
+ ].join('\n');
577
+ return {
578
+ action: 'extension_permission_gate_built',
579
+ component: 'PermissionGate',
580
+ snippet,
581
+ normalizedBodyChanges: normalized.changes,
582
+ warnings: condition === 'null' ? ['No condition/route was provided. PermissionGate with null condition permits the slot.'] : [],
583
+ contract: [
584
+ 'PermissionGate is only operator UX; backend route permissions and owner checks remain authoritative.',
585
+ 'PermissionGate renders its slot directly and should not be used as a layout wrapper.',
586
+ ],
587
+ };
588
+ }
589
+ export function buildExtensionEmptyStateSnippet(input) {
590
+ const action = input.action
591
+ ? `\n :action="${buildFooterActionObject(input.action)}"`
592
+ : '';
593
+ return {
594
+ action: 'extension_empty_state_built',
595
+ component: 'CommonEmptyState',
596
+ snippet: `<CommonEmptyState\n title="${String(input.title || 'No items found').replace(/"/g, '&quot;')}"\n description="${String(input.description || '').replace(/"/g, '&quot;')}"\n icon="${input.icon || 'lucide:inbox'}"\n size="${input.size || 'sm'}"\n variant="${input.variant || 'naked'}"${action}\n/>`,
597
+ contract: [
598
+ 'Use CommonEmptyState for app-matched empty states.',
599
+ 'Use variant="naked" inside framed panels/lists and outline/subtle for standalone framed empty surfaces.',
600
+ ],
601
+ };
602
+ }
603
+ export function buildExtensionResourceListSnippet(input) {
604
+ const itemsExpression = input.itemsExpression || 'items';
605
+ const itemName = input.itemName || 'item';
606
+ const keyExpression = input.keyExpression || `${itemName}.id`;
607
+ const titleExpression = input.titleExpression || `${itemName}.title || ${quoteJsString('Untitled')}`;
608
+ const descriptionExpression = input.descriptionExpression || `${itemName}.description`;
609
+ const iconExpression = input.iconExpression || quoteJsString(input.icon || 'lucide:file-text');
610
+ const onClick = input.onClick ? `\n :on-click="() => ${input.onClick}"` : '';
611
+ const stats = input.statsExpression ? `\n :stats="${input.statsExpression}"` : '';
612
+ const actions = input.actionsExpression ? `\n :actions="${input.actionsExpression}"` : '';
613
+ const topBadge = input.topBadgeExpression ? `\n :top-badge="${input.topBadgeExpression}"` : '';
614
+ const snippet = [
615
+ '<CommonResourceListFrame',
616
+ ` :loading="${input.loadingExpression || 'pending'}"`,
617
+ ` :has-items="${itemsExpression}.length > 0"`,
618
+ ` :total="${input.totalExpression || `${itemsExpression}.length`}"`,
619
+ ` :items-per-page="${input.itemsPerPageExpression || '0'}"`,
620
+ ` empty-title="${String(input.emptyTitle || 'No items found').replace(/"/g, '&quot;')}"`,
621
+ ` empty-description="${String(input.emptyDescription || '').replace(/"/g, '&quot;')}"`,
622
+ ` empty-icon="${input.emptyIcon || 'lucide:inbox'}"`,
623
+ '>',
624
+ ` <CommonResourceListItem`,
625
+ ` v-for="${itemName} in ${itemsExpression}"`,
626
+ ` :key="${keyExpression}"`,
627
+ ` :title="${titleExpression}"`,
628
+ ` :description="${descriptionExpression}"`,
629
+ ` :icon="${iconExpression}"`,
630
+ ' icon-color="primary"',
631
+ `${stats}${actions}${topBadge}${onClick}`,
632
+ ' />',
633
+ '</CommonResourceListFrame>',
634
+ ].join('\n');
635
+ return {
636
+ action: 'extension_resource_list_built',
637
+ components: ['CommonResourceListFrame', 'CommonResourceListItem'],
638
+ snippet,
639
+ contract: [
640
+ 'Use CommonResourceListFrame and CommonResourceListItem for operational lists instead of ad hoc cards.',
641
+ 'Keep first-load skeleton, empty state, and pagination owned by the frame.',
642
+ 'Use explicit bounded list data and natural pagination/search outside this snippet when the domain list can grow.',
643
+ ],
644
+ };
645
+ }
646
+ export function reviewExtensionUiContract(code) {
647
+ const source = String(code || '');
648
+ const issues = [];
649
+ const push = (severity, rule, message, suggestion) => issues.push({ severity, rule, message, suggestion });
650
+ if (/<CommonDrawer\b[^>]*(?:\s:title=|\stitle=)/.test(source)) {
651
+ push('error', 'common-drawer-slots', 'CommonDrawer should not use title/:title props in generated extensions.', 'Use #header with a heading, and #body for content.');
652
+ }
653
+ if (/<(?:CommonModal|UModal)\b[^>]*(?:\s:title=|\stitle=)/.test(source)) {
654
+ push('error', 'common-modal-slots', 'CommonModal/UModal should not use title/:title props in generated extensions.', 'Use #header with a heading, and #body for content.');
655
+ }
656
+ if (/<CommonDrawer\b/.test(source) && !/primary-action=/.test(source)) {
657
+ push('warning', 'drawer-primary-action', 'CommonDrawer has no primaryAction.', 'Editing/create drawers should wire Save/Create through primaryAction.');
658
+ }
659
+ if (/<CommonDrawer\b/.test(source) && !/cancel-action=/.test(source)) {
660
+ push('warning', 'drawer-cancel-action', 'CommonDrawer has no cancelAction.', 'Use cancelAction for the ordinary Cancel footer button unless the workflow intentionally has no cancel.');
661
+ }
662
+ if (/<(?:CommonModal|UModal)\b/.test(source) && /delete|remove|confirm|cannot be undone/i.test(source) && !/danger-action=/.test(source)) {
663
+ push('warning', 'modal-danger-action', 'Destructive/confirmation modal has no dangerAction.', 'Wire the final destructive action through dangerAction.');
664
+ }
665
+ const fieldPattern = /<(UInput|UTextarea|USelectMenu|USelect)(\s[^<>]*?)\/?>/g;
666
+ let fieldMatch;
667
+ while ((fieldMatch = fieldPattern.exec(source))) {
668
+ const [, tag, attrs] = fieldMatch;
669
+ const classMatch = attrs.match(/\bclass="([^"]*)"/);
670
+ if (!classMatch || !classMatch[1].split(/\s+/).includes('w-full')) {
671
+ push('warning', 'modal-drawer-field-width', `${tag} is missing class="w-full".`, 'Use class="w-full" for form controls inside modal/drawer body forms unless intentionally inline.');
672
+ }
673
+ }
674
+ const buttonPattern = /<button(\s[^>]*)?>/g;
675
+ let buttonMatch;
676
+ while ((buttonMatch = buttonPattern.exec(source))) {
677
+ if (!/\btype=/.test(buttonMatch[1] || '')) {
678
+ push('warning', 'native-button-type', 'Native button is missing type="button".', 'Add type="button" unless the button intentionally submits a form.');
679
+ }
680
+ }
681
+ return {
682
+ action: 'extension_ui_contract_reviewed',
683
+ valid: issues.every((issue) => issue.severity !== 'error'),
684
+ issueCount: issues.length,
685
+ issues,
686
+ nextSteps: issues.length
687
+ ? ['Use build_extension_drawer or build_extension_modal for replacement snippets, then apply with patch_extension_code/update_extension_code.']
688
+ : ['Snippet matches the checked modal/drawer contract rules. Still validate the final SFC before saving.'],
689
+ };
690
+ }
302
691
  function getExtensionThemeContract() {
303
692
  return {
304
693
  action: 'extension_theme_contract',
@@ -364,6 +753,7 @@ function getExtensionThemeContract() {
364
753
  'Buttons should have stable geometry: hover may change color, border, or shadow but must not move the button or resize its content. Disabled buttons keep disabled cursor/visual state.',
365
754
  'Inputs and textareas should not add hover movement or decorative hover states; focus, invalid, disabled, and loading states must be explicit.',
366
755
  'Inside CommonDrawer/CommonModal body forms, UInput, UTextarea, USelect, USelectMenu, and similar field controls must use class="w-full" unless the control is intentionally inline or compact.',
756
+ 'Extension validation rejects UInput, UTextarea, USelect, USelectMenu, UInputMenu, UInputNumber, UInputTags, UInputTime, and UInputDate without class="w-full" unless marked data-compact or data-inline.',
367
757
  'Dynamic extensions resolve UModal to the app CommonModal. Do not pass ui.content: "eapp-surface-card" or "surface-card" to UModal/CommonModal; modal content uses the app modal surface and caller ui.content should only append z-index, width, or max-width classes.',
368
758
  'CommonModal and CommonDrawer own action-only footers through cancelAction, primaryAction, dangerAction, leadingActions, and footerHint. Pass footer button intent through those props instead of custom footer slots. cancelAction defaults to neutral outline; use dangerAction for irreversible destructive work and tone: "primary" for Keep editing in discard dialogs.',
369
759
  'Use custom #footer content only when the footer contains real custom layout or non-button content. Every modal/drawer button should use type="button" unless it intentionally submits a form.',
@@ -644,7 +1034,12 @@ export function validateExtensionCodeLocally(code) {
644
1034
  const first = violations[0];
645
1035
  throw new Error(`Invalid extension component casing: use <${first.expected}> instead of <${first.tag}>. Enfyra/Nuxt UI auto-injected components must keep PascalCase in extension templates; lowercase tags render as unresolved DOM elements.`);
646
1036
  }
647
- return { componentCasing: 'passed' };
1037
+ const missingFullWidthFields = findMissingFullWidthFieldControls(code);
1038
+ if (missingFullWidthFields.length) {
1039
+ const first = missingFullWidthFields[0];
1040
+ throw new Error(`Invalid extension field width: <${first.tag}> must include class="w-full" in Enfyra extensions unless it is intentionally compact with data-compact or data-inline. First offending snippet: ${first.snippet}`);
1041
+ }
1042
+ return { componentCasing: 'passed', fieldWidth: 'passed' };
648
1043
  }
649
1044
  export async function validateExtensionCode(apiUrl, code, name) {
650
1045
  const localChecks = validateExtensionCodeLocally(code);
@@ -1845,6 +2240,30 @@ async function runExtensionWorkflow(apiUrl, opts) {
1845
2240
  };
1846
2241
  }
1847
2242
  export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
2243
+ const extensionFooterActionSchema = z.object({
2244
+ label: z.string().optional().describe('Static action label. Use labelExpression instead for dynamic labels.'),
2245
+ labelExpression: z.string().optional().describe('Raw Vue expression for a dynamic label, e.g. mode === "create" ? "Create" : "Save".'),
2246
+ icon: z.string().optional().describe('Optional icon name such as lucide:save or lucide:trash-2.'),
2247
+ loading: z.string().optional().describe('Raw Vue expression/ref name for loading state, e.g. saving.'),
2248
+ disabled: z.string().optional().describe('Raw Vue expression/ref name for disabled state, e.g. saving || !canSubmit.'),
2249
+ color: z.string().optional().describe('Optional component-supported color. Usually omit and let the managed action choose intent.'),
2250
+ variant: z.string().optional().describe('Optional component-supported variant. Usually omit and let the managed action choose intent.'),
2251
+ tone: z.string().optional().describe('Optional action tone when supported by the shell component.'),
2252
+ onClick: z.string().describe('Raw Vue expression or function reference for the click handler, e.g. saveNote or () => (open = false).'),
2253
+ });
2254
+ const extensionHeaderActionSchema = z.object({
2255
+ id: z.string().describe('Stable action id.'),
2256
+ label: z.string().optional().describe('Action label.'),
2257
+ icon: z.string().optional().describe('Icon name such as lucide:plus or lucide:refresh-cw.'),
2258
+ color: z.string().optional().default('neutral').describe('Nuxt UI color. Use primary only for the single main scope action; otherwise neutral.'),
2259
+ variant: z.string().optional().default('outline').describe('Nuxt UI variant. Use solid for the main scope action; otherwise outline/ghost.'),
2260
+ loading: z.string().optional().describe('Raw Vue expression/ref name for loading state.'),
2261
+ disabled: z.string().optional().describe('Raw Vue expression/ref name for disabled state.'),
2262
+ to: z.string().optional().describe('Route path for visible navigation actions.'),
2263
+ onClick: z.string().optional().describe('Raw Vue expression or function reference for click behavior.'),
2264
+ order: z.number().optional().describe('Sort order in the shell header action area.'),
2265
+ side: z.enum(['left', 'right']).optional().describe('Optional shell side.'),
2266
+ });
1848
2267
  server.tool('validate_dynamic_script', [
1849
2268
  'Validate Enfyra dynamic script code before saving it to any script-backed metadata record.',
1850
2269
  'Use this before create/update of handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts when the user is iterating on code.',
@@ -1918,6 +2337,99 @@ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1918
2337
  'Call this whenever you need the exact eapp-* class name or the Nuxt UI color mapping for shell, system page, or dynamic extension UI.',
1919
2338
  'Source of truth: documents/app/theme-color-contract.md.',
1920
2339
  ].join(' '), {}, async () => jsonText(getThemeClassReference()));
2340
+ server.tool('build_extension_drawer', [
2341
+ 'Generate a contract-safe CommonDrawer Vue snippet for Enfyra admin extensions.',
2342
+ 'Use this before writing or patching drawer/editing workflows so the model does not have to remember CommonDrawer slots, footer action props, full-width fields, or button type rules.',
2343
+ 'The tool returns code only; apply it with patch_extension_code or update_extension_code and then validate/save normally.',
2344
+ ].join(' '), {
2345
+ model: z.string().optional().default('drawerOpen').describe('Vue state variable used with v-model.'),
2346
+ title: z.string().optional().describe('Static drawer title.'),
2347
+ titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
2348
+ direction: z.enum(['right', 'left', 'top', 'bottom']).optional().default('right').describe('Drawer direction.'),
2349
+ nested: z.boolean().optional().default(false).describe('Set true when rendering a drawer inside another modal/drawer.'),
2350
+ handleOnly: z.boolean().optional().default(false).describe('Set true only when the drawer should use handle-only behavior.'),
2351
+ body: z.string().describe('Vue template body content for #body. UInput/UTextarea/select controls are normalized to w-full; native buttons get type="button".'),
2352
+ cancelAction: z.union([extensionFooterActionSchema, z.literal(false)]).optional().describe('Cancel action object. Omit for a default Cancel that closes the model; false disables cancelAction.'),
2353
+ primaryAction: extensionFooterActionSchema.optional().describe('Primary action. Editing/create drawers should wire Save/Create here.'),
2354
+ dangerAction: extensionFooterActionSchema.optional().describe('Danger action. Destructive edit drawers should wire Delete here.'),
2355
+ footerHint: z.string().optional().describe('Optional footer hint text when supported by CommonDrawer.'),
2356
+ }, async (input) => jsonText(buildExtensionDrawerSnippet(input)));
2357
+ server.tool('build_extension_modal', [
2358
+ 'Generate a contract-safe CommonModal/UModal Vue snippet for Enfyra admin extensions.',
2359
+ 'Use this before writing or patching confirmation/edit modals so the model does not have to remember v-model:open, slots, final action props, full-width fields, or button type rules.',
2360
+ 'The tool returns code only; apply it with patch_extension_code or update_extension_code and then validate/save normally.',
2361
+ ].join(' '), {
2362
+ model: z.string().optional().default('modalOpen').describe('Vue state variable used with v-model:open.'),
2363
+ title: z.string().optional().describe('Static modal title.'),
2364
+ titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
2365
+ alias: z.enum(['CommonModal', 'UModal']).optional().default('CommonModal').describe('Use UModal only when preserving an existing UModal tag is useful; dynamic extensions resolve it to CommonModal.'),
2366
+ body: z.string().describe('Vue template body content for #body. UInput/UTextarea/select controls are normalized to w-full; native buttons get type="button".'),
2367
+ cancelAction: z.union([extensionFooterActionSchema, z.literal(false)]).optional().describe('Cancel action object. Omit for a default Cancel that closes the model; false disables cancelAction.'),
2368
+ primaryAction: extensionFooterActionSchema.optional().describe('Primary final action for non-destructive submit/confirm flows.'),
2369
+ dangerAction: extensionFooterActionSchema.optional().describe('Danger final action for destructive confirmation flows.'),
2370
+ footerHint: z.string().optional().describe('Optional footer hint text when supported by CommonModal.'),
2371
+ }, async (input) => jsonText(buildExtensionModalSnippet(input)));
2372
+ server.tool('review_extension_ui_contract', [
2373
+ 'Review an Enfyra extension Vue snippet for common modal/drawer contract mistakes.',
2374
+ 'Use this before patching or saving generated extension UI when CommonDrawer, CommonModal, UModal, UInput, UTextarea, USelect, or native buttons are involved.',
2375
+ 'This is a static contract review, not a compiler validation; still validate the final SFC before saving.',
2376
+ ].join(' '), {
2377
+ code: z.string().describe('Vue SFC or template snippet to review.'),
2378
+ }, async ({ code }) => jsonText(reviewExtensionUiContract(code)));
2379
+ server.tool('build_extension_page_shell', [
2380
+ 'Generate page-header and shell-header-action script setup code for Enfyra page extensions.',
2381
+ 'Use this so generated page extensions register shell chrome through usePageHeaderRegistry/useHeaderActionRegistry instead of rendering duplicate local headers.',
2382
+ ].join(' '), {
2383
+ title: z.string().optional().describe('Static page title.'),
2384
+ titleExpression: z.string().optional().describe('Raw Vue expression for a dynamic title.'),
2385
+ description: z.string().optional().describe('Optional page description.'),
2386
+ leadingIcon: z.string().optional().describe('Optional page header icon.'),
2387
+ gradient: z.enum(['none', 'purple', 'blue', 'cyan']).optional().default('none').describe('Generated operational extensions should usually use none.'),
2388
+ variant: z.enum(['default', 'minimal', 'stats-focus']).optional().default('minimal').describe('Page header variant.'),
2389
+ headerActions: z.array(extensionHeaderActionSchema).optional().describe('Optional shell header actions registered through useHeaderActionRegistry.'),
2390
+ }, async (input) => jsonText(buildExtensionPageShellSnippet(input)));
2391
+ server.tool('build_extension_permission_gate', [
2392
+ 'Generate a PermissionGate wrapper snippet for Enfyra admin extension UI.',
2393
+ 'Use this when a visible button/block/list needs operator UX gating; backend route permissions and owner checks still remain authoritative.',
2394
+ ].join(' '), {
2395
+ route: z.string().optional().describe('API route path to gate against, e.g. /notes.'),
2396
+ methods: z.array(z.string()).optional().describe('HTTP methods for the route condition. Defaults to GET when route is provided.'),
2397
+ condition: z.string().optional().describe('Raw Vue permission condition expression. Overrides route/methods when provided.'),
2398
+ body: z.string().describe('Vue template content to render inside PermissionGate. Field controls are normalized to w-full.'),
2399
+ }, async (input) => jsonText(buildExtensionPermissionGateSnippet(input)));
2400
+ server.tool('build_extension_empty_state', [
2401
+ 'Generate a CommonEmptyState snippet for Enfyra admin extensions.',
2402
+ 'Use this for app-matched empty/error/no-results states instead of hand-rolled blank panels.',
2403
+ ].join(' '), {
2404
+ title: z.string().optional().describe('Empty state title.'),
2405
+ description: z.string().optional().describe('Empty state description.'),
2406
+ icon: z.string().optional().describe('Icon name. Defaults to lucide:inbox.'),
2407
+ size: z.enum(['sm', 'md', 'lg']).optional().default('sm').describe('Empty state size.'),
2408
+ variant: z.enum(['outline', 'naked', 'soft', 'subtle', 'solid']).optional().default('naked').describe('Use naked inside existing panels/lists.'),
2409
+ action: extensionFooterActionSchema.optional().describe('Optional primary empty-state action.'),
2410
+ }, async (input) => jsonText(buildExtensionEmptyStateSnippet(input)));
2411
+ server.tool('build_extension_resource_list', [
2412
+ 'Generate a CommonResourceListFrame/CommonResourceListItem snippet for Enfyra admin extensions.',
2413
+ 'Use this for operational list pages so loading, empty state, pagination placement, row chrome, icons, stats, and row actions follow the app contract.',
2414
+ ].join(' '), {
2415
+ itemsExpression: z.string().optional().default('items').describe('Vue expression for the row array, e.g. notes.'),
2416
+ itemName: z.string().optional().default('item').describe('Loop variable name.'),
2417
+ keyExpression: z.string().optional().describe('Vue expression for :key. Defaults to item.id.'),
2418
+ titleExpression: z.string().optional().describe('Vue expression for item title. Defaults to item.title || "Untitled".'),
2419
+ descriptionExpression: z.string().optional().describe('Vue expression for item description. Defaults to item.description.'),
2420
+ icon: z.string().optional().describe('Static row icon when iconExpression is omitted.'),
2421
+ iconExpression: z.string().optional().describe('Vue expression for row icon.'),
2422
+ loadingExpression: z.string().optional().default('pending').describe('Vue expression for frame loading.'),
2423
+ totalExpression: z.string().optional().describe('Vue expression for total rows.'),
2424
+ itemsPerPageExpression: z.string().optional().describe('Vue expression for items per page; use 0 to hide pagination.'),
2425
+ statsExpression: z.string().optional().describe('Vue expression returning ResourceListStat[] for each row.'),
2426
+ actionsExpression: z.string().optional().describe('Vue expression returning ResourceListAction[] for each row.'),
2427
+ topBadgeExpression: z.string().optional().describe('Vue expression returning a ResourceListTopBadge for each row.'),
2428
+ onClick: z.string().optional().describe('Raw Vue expression called for row click, e.g. openEdit(item).'),
2429
+ emptyTitle: z.string().optional().describe('Empty title.'),
2430
+ emptyDescription: z.string().optional().describe('Empty description.'),
2431
+ emptyIcon: z.string().optional().describe('Empty icon.'),
2432
+ }, async (input) => jsonText(buildExtensionResourceListSnippet(input)));
1921
2433
  server.tool('extension_workflow', [
1922
2434
  'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
1923
2435
  'Use this when an LLM is building extension UI, menu shell notifications, account panel entries, or page/menu wiring and should follow live nextSteps instead of guessing raw enfyra_extension mutations.',