@neuctra/ui-mcp 0.4.4 → 0.4.5
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/README.md +69 -150
- package/data/layouts.json +790 -0
- package/data/seoGuide.json +100 -6
- package/data/theme.json +1 -1
- package/package.json +1 -1
- package/src/load-data.mjs +20 -0
- package/src/seo.mjs +3 -3
- package/src/server.mjs +19 -495
- package/src/tools/get-design-rules/index.mjs +42 -0
- package/src/tools/get-neuctra-ui-component/index.mjs +160 -0
- package/src/tools/neuctra-ui-page-builder/index.mjs +157 -0
- package/src/tools/neuctra-ui-seo-setup/index.mjs +62 -0
- package/src/tools/neuctra-ui-setup/index.mjs +114 -0
- package/src/tools/neuctra-ui-setup/register.mjs +28 -0
- package/src/tools/neuctra-ui-theme-setup/index.mjs +17 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const COMPONENT_CATEGORIES = [
|
|
4
|
+
"layout", "typography", "form", "actions", "data-display", "feedback", "overlay", "navigation",
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const SYNONYMS = {
|
|
8
|
+
layouting: "layout container section grid",
|
|
9
|
+
layout: "layout container section grid",
|
|
10
|
+
form: "form input field select checkbox radio textarea",
|
|
11
|
+
forms: "form input field select checkbox radio textarea",
|
|
12
|
+
field: "input form field",
|
|
13
|
+
fields: "input form field",
|
|
14
|
+
image: "image media picture avatar",
|
|
15
|
+
images: "image media picture avatar",
|
|
16
|
+
carousel: "carousel slider slides",
|
|
17
|
+
carousels: "carousel slider slides",
|
|
18
|
+
slider: "carousel slider slides",
|
|
19
|
+
modal: "modal dialog overlay popup",
|
|
20
|
+
dialog: "modal dialog overlay popup",
|
|
21
|
+
dialogs: "modal dialog overlay popup",
|
|
22
|
+
drawer: "drawer panel sheet overlay",
|
|
23
|
+
drawers: "drawer panel sheet overlay",
|
|
24
|
+
card: "card panel surface",
|
|
25
|
+
cards: "card panel surface",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STOP_WORDS = new Set(["a", "an", "and", "for", "i", "in", "it", "of", "on", "the", "to", "ui", "use", "with"]);
|
|
29
|
+
|
|
30
|
+
function tokens(text) {
|
|
31
|
+
return text
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
34
|
+
.split(/[^a-z0-9]+/)
|
|
35
|
+
.filter((token) => token && !STOP_WORDS.has(token));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function searchableText(component) {
|
|
39
|
+
return [
|
|
40
|
+
component.name,
|
|
41
|
+
component.category,
|
|
42
|
+
component.description,
|
|
43
|
+
...component.props.flatMap((prop) => [prop.name, prop.description, prop.type]),
|
|
44
|
+
].join(" ").toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rankComponent(component, query) {
|
|
48
|
+
const name = component.name.toLowerCase();
|
|
49
|
+
const haystack = searchableText(component);
|
|
50
|
+
const queryText = query.toLowerCase().trim();
|
|
51
|
+
const queryTokens = tokens(queryText);
|
|
52
|
+
let score = 0;
|
|
53
|
+
|
|
54
|
+
if (name === queryText) score += 100;
|
|
55
|
+
else if (name.includes(queryText) && queryText) score += 50;
|
|
56
|
+
|
|
57
|
+
for (const token of queryTokens) {
|
|
58
|
+
const expanded = tokens(SYNONYMS[token] ?? token);
|
|
59
|
+
for (const term of expanded) {
|
|
60
|
+
if (name === term) score += 32;
|
|
61
|
+
else if (name.includes(term)) score += 18;
|
|
62
|
+
if (component.category.toLowerCase() === term) score += 10;
|
|
63
|
+
if (haystack.includes(term)) score += 3;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return score;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summarize(component) {
|
|
71
|
+
return {
|
|
72
|
+
name: component.name,
|
|
73
|
+
import: `import { ${component.name} } from '${component.importFrom}';`,
|
|
74
|
+
category: component.category,
|
|
75
|
+
description: component.description,
|
|
76
|
+
propCount: component.props.length,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function findNeuctraUiComponents(registry, request, { category, limit = 5 } = {}) {
|
|
81
|
+
if (!request?.trim()) return [];
|
|
82
|
+
return registry.components
|
|
83
|
+
.filter((component) => !category || component.category === category)
|
|
84
|
+
.map((component) => ({ component, score: rankComponent(component, request) }))
|
|
85
|
+
.filter(({ score }) => score > 0)
|
|
86
|
+
.sort((a, b) => b.score - a.score || a.component.name.localeCompare(b.component.name))
|
|
87
|
+
.slice(0, limit)
|
|
88
|
+
.map(({ component }) => component);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function registerGetNeuctraUiComponentTool(server, registry) {
|
|
92
|
+
const componentsByName = new Map(
|
|
93
|
+
registry.components.map((component) => [component.name.toLowerCase(), component]),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
server.registerTool(
|
|
97
|
+
"get-neuctra-ui-component",
|
|
98
|
+
{
|
|
99
|
+
title: "Get Neuctra UI components for a UI need",
|
|
100
|
+
description:
|
|
101
|
+
"Find and retrieve the real @neuctra/ui components that fit a natural-language UI requirement, such as layout containers, input forms, images, carousels, cards, modals, or drawers. Returns exact package imports, full prop specifications, and documented examples for ranked matches. Use this before implementing with Neuctra UI; do not guess component names or props. Set category to browse a category or omit request/category to list components.",
|
|
102
|
+
inputSchema: {
|
|
103
|
+
request: z.string().optional().describe("Describe the UI need naturally, e.g. 'a dismissible confirmation dialog' or 'responsive carousel for product images'."),
|
|
104
|
+
componentName: z.string().optional().describe("Exact component name when already known, e.g. Container, Input, Carousel, Card, Modal, or Drawer."),
|
|
105
|
+
category: z.enum(COMPONENT_CATEGORIES).optional().describe("Optionally restrict component discovery to a library category."),
|
|
106
|
+
limit: z.number().int().min(1).max(10).default(5).describe("Maximum number of full component specs to return for a natural-language request."),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
async ({ request, componentName, category, limit }) => {
|
|
110
|
+
if (componentName) {
|
|
111
|
+
const exact = componentsByName.get(componentName.toLowerCase());
|
|
112
|
+
if (exact && (!category || exact.category === category)) {
|
|
113
|
+
return { content: [{ type: "text", text: JSON.stringify({ components: [exact] }, null, 2) }] };
|
|
114
|
+
}
|
|
115
|
+
if (!request?.trim()) {
|
|
116
|
+
const suggestions = registry.components
|
|
117
|
+
.filter((component) => (!category || component.category === category)
|
|
118
|
+
&& component.name.toLowerCase().includes(componentName.toLowerCase()))
|
|
119
|
+
.slice(0, 8)
|
|
120
|
+
.map(summarize);
|
|
121
|
+
return {
|
|
122
|
+
isError: true,
|
|
123
|
+
content: [{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: JSON.stringify({
|
|
126
|
+
message: `No exact component named "${componentName}"${category ? ` in category "${category}"` : ""}.`,
|
|
127
|
+
suggestions,
|
|
128
|
+
}, null, 2),
|
|
129
|
+
}],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!request?.trim()) {
|
|
135
|
+
const components = registry.components
|
|
136
|
+
.filter((component) => !category || component.category === category)
|
|
137
|
+
.map(summarize);
|
|
138
|
+
return { content: [{ type: "text", text: JSON.stringify({ components }, null, 2) }] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ranked = findNeuctraUiComponents(registry, request, { category, limit });
|
|
142
|
+
|
|
143
|
+
if (!ranked.length) {
|
|
144
|
+
const suggestions = registry.components
|
|
145
|
+
.filter((component) => !category || component.category === category)
|
|
146
|
+
.map(summarize);
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: "text", text: JSON.stringify({ message: "No component matched that request. Browse these available components or refine the request.", suggestions }, null, 2) }],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
content: [{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: JSON.stringify({ request, components: ranked }, null, 2),
|
|
156
|
+
}],
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { FRAMEWORKS } from "../../seo.mjs";
|
|
3
|
+
import { findNeuctraUiComponents } from "../get-neuctra-ui-component/index.mjs";
|
|
4
|
+
import { getDesignRulesContext } from "../get-design-rules/index.mjs";
|
|
5
|
+
import { buildSeoGuideContext } from "../neuctra-ui-seo-setup/index.mjs";
|
|
6
|
+
|
|
7
|
+
function resolveLayout(request, requestedType, layoutData) {
|
|
8
|
+
const layouts = layoutData?.layouts ?? [];
|
|
9
|
+
if (requestedType !== "auto") {
|
|
10
|
+
const explicit = layouts.find((layout) => layout.id === requestedType);
|
|
11
|
+
if (explicit) return { layout: explicit, confidence: 1, evidence: [`Explicit page type: ${requestedType}`] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const normalized = request.toLowerCase();
|
|
15
|
+
const scored = layouts
|
|
16
|
+
.filter((layout) => layout.id !== "custom")
|
|
17
|
+
.map((layout) => {
|
|
18
|
+
let score = 0;
|
|
19
|
+
const evidence = [];
|
|
20
|
+
for (const alias of layout.aliases ?? []) {
|
|
21
|
+
if (normalized.includes(alias.toLowerCase())) {
|
|
22
|
+
score += alias.includes(" ") ? 5 : 3;
|
|
23
|
+
evidence.push(alias);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const keyword of layout.keywords ?? []) {
|
|
27
|
+
const pattern = new RegExp(`\\b${keyword}\\b`, "i");
|
|
28
|
+
if (pattern.test(normalized)) {
|
|
29
|
+
score += 1;
|
|
30
|
+
evidence.push(keyword);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { layout, score, evidence };
|
|
34
|
+
})
|
|
35
|
+
.sort((a, b) => b.score - a.score);
|
|
36
|
+
|
|
37
|
+
const best = scored[0];
|
|
38
|
+
const custom = layouts.find((layout) => layout.id === "custom") ?? layouts[0];
|
|
39
|
+
if (!best || best.score === 0) {
|
|
40
|
+
return { layout: custom, confidence: 0.25, evidence: ["No specific page pattern detected; use a custom structure based on the stated task."] };
|
|
41
|
+
}
|
|
42
|
+
const nextScore = scored[1]?.score ?? 0;
|
|
43
|
+
return {
|
|
44
|
+
layout: best.layout,
|
|
45
|
+
confidence: Math.min(0.98, 0.55 + best.score / (best.score + nextScore + 4)),
|
|
46
|
+
evidence: best.evidence.slice(0, 8),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectTaskSignals(request) {
|
|
51
|
+
return [
|
|
52
|
+
["create", /\b(create|build|make|new|design)\b/i],
|
|
53
|
+
["review", /\b(review|view|inspect|monitor|track|activity|analytics)\b/i],
|
|
54
|
+
["search", /\b(search|find|filter|sort)\b/i],
|
|
55
|
+
["edit", /\b(edit|update|change|manage|configure)\b/i],
|
|
56
|
+
["complete", /\b(complete|submit|checkout|send|publish|save|convert|upload)\b/i],
|
|
57
|
+
["compare", /\b(compare|pricing|plan|option|alternative)\b/i],
|
|
58
|
+
].filter(([, pattern]) => pattern.test(request)).map(([signal]) => signal);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getComponentGuidance(registry, request, layout) {
|
|
62
|
+
const candidates = new Map();
|
|
63
|
+
const queries = [request, ...(layout.componentQueries ?? [])];
|
|
64
|
+
for (const query of queries) {
|
|
65
|
+
for (const component of findNeuctraUiComponents(registry, query, { limit: query === request ? 5 : 2 })) {
|
|
66
|
+
if (!candidates.has(component.name)) candidates.set(component.name, component);
|
|
67
|
+
if (candidates.size >= 12) return [...candidates.values()];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...candidates.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function registerNeuctraUiPageBuilderTool(server, data) {
|
|
74
|
+
const layouts = data.layouts?.layouts ?? [];
|
|
75
|
+
const pageTypeOptions = ["auto", ...layouts.map((layout) => layout.id)];
|
|
76
|
+
|
|
77
|
+
server.registerTool(
|
|
78
|
+
"neuctra-ui-page-builder",
|
|
79
|
+
{
|
|
80
|
+
title: "Analyze and plan a Neuctra UI page",
|
|
81
|
+
description:
|
|
82
|
+
"Analyze a page request, infer its page type (for example tool, blog, dashboard, form, ecommerce, or docs), select a matching editable recipe from data/layouts.json, then compose matching @neuctra/ui component specs, design rules, and framework-aware SEO rules. Use this as the page-planning entry point. It plans the page; the assistant implements it using the returned layout and exact component APIs. Layout recipes can be added or edited in data/layouts.json.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
request: z.string().min(8).describe("Describe the page to create and the main user task."),
|
|
85
|
+
pageType: z.enum(pageTypeOptions).default("auto").describe("Page type or auto to infer the closest layout from the request."),
|
|
86
|
+
framework: z.enum(["auto", ...FRAMEWORKS, "remix"]).default("auto").describe("Framework and router for framework-specific SEO implementation guidance."),
|
|
87
|
+
preferences: z.object({
|
|
88
|
+
visualDirection: z.string().optional(),
|
|
89
|
+
density: z.enum(["compact", "balanced", "spacious"]).optional(),
|
|
90
|
+
primaryAction: z.string().optional(),
|
|
91
|
+
platform: z.enum(["desktop", "mobile", "responsive"]).default("responsive"),
|
|
92
|
+
audience: z.string().optional(),
|
|
93
|
+
}).default({}).describe("Optional design and audience preferences."),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
async ({ request, pageType, framework, preferences }) => {
|
|
97
|
+
const { layout, confidence, evidence } = resolveLayout(request, pageType, data.layouts);
|
|
98
|
+
if (!layout) {
|
|
99
|
+
return { isError: true, content: [{ type: "text", text: "No layouts are configured. Add layout recipes to data/layouts.json." }] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const components = getComponentGuidance(data.registry, request, layout);
|
|
103
|
+
const design = getDesignRulesContext(data.theme, data.aiDesignRules, layout.designRuleNumbers);
|
|
104
|
+
const seo = buildSeoGuideContext(data.seoGuide, {
|
|
105
|
+
framework,
|
|
106
|
+
ruleIds: layout.seoRuleIds,
|
|
107
|
+
});
|
|
108
|
+
const signals = detectTaskSignals(request);
|
|
109
|
+
const preferencesToClarify = [];
|
|
110
|
+
if (!preferences.visualDirection) preferencesToClarify.push("visual direction");
|
|
111
|
+
if (!preferences.density) preferencesToClarify.push("content density");
|
|
112
|
+
if (!preferences.primaryAction && layout.id !== "blog-page" && layout.id !== "documentation") preferencesToClarify.push("primary action");
|
|
113
|
+
|
|
114
|
+
const brief = {
|
|
115
|
+
pageAnalysis: {
|
|
116
|
+
request,
|
|
117
|
+
inferredPageType: layout.id,
|
|
118
|
+
layoutName: layout.name,
|
|
119
|
+
confidence,
|
|
120
|
+
evidence,
|
|
121
|
+
detectedTaskSignals: signals,
|
|
122
|
+
userGoal: signals.length
|
|
123
|
+
? `Support the requested ${signals.join(", ")} task with a clear primary path.`
|
|
124
|
+
: "Use the requested page outcome to define the primary path without adding unrelated features.",
|
|
125
|
+
},
|
|
126
|
+
layout: {
|
|
127
|
+
purpose: layout.purpose,
|
|
128
|
+
outline: layout.outline,
|
|
129
|
+
responsiveNotes: layout.responsiveNotes,
|
|
130
|
+
userPreferences: preferences,
|
|
131
|
+
preferencesToClarify,
|
|
132
|
+
},
|
|
133
|
+
components: {
|
|
134
|
+
source: "@neuctra/ui component registry",
|
|
135
|
+
matches: components,
|
|
136
|
+
instruction: "Use these exact exports and documented props. If a required component is absent from the matches, call get-neuctra-ui-component for that need before inventing a pattern.",
|
|
137
|
+
},
|
|
138
|
+
designRules: design,
|
|
139
|
+
seoGuidance: seo ? {
|
|
140
|
+
framework: seo.framework,
|
|
141
|
+
relevantRules: seo.seoRules,
|
|
142
|
+
implementationSteps: seo.implementationSteps,
|
|
143
|
+
layoutSpecificNotes: layout.seoNotes,
|
|
144
|
+
} : null,
|
|
145
|
+
implementationInstructions: [
|
|
146
|
+
"Use the selected layout as a starting structure and adapt its sections to the exact request.",
|
|
147
|
+
"Keep the primary task visually clear; do not add sections that do not serve the request.",
|
|
148
|
+
"Use Neuctra UI components with the exact props and semantic theme tokens from the supplied context.",
|
|
149
|
+
"Apply the SEO steps only when the page is public and indexable; protect private application pages.",
|
|
150
|
+
"When framework is auto, inspect the project files and choose metadata and rendering APIs for the installed framework and router.",
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return { content: [{ type: "text", text: JSON.stringify(brief, null, 2) }] };
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { FRAMEWORKS } from "../../seo.mjs";
|
|
3
|
+
|
|
4
|
+
export function buildSeoGuideContext(seoGuide, { framework = "auto", ruleIds } = {}) {
|
|
5
|
+
if (!seoGuide) return null;
|
|
6
|
+
const selectedRules = ruleIds
|
|
7
|
+
? seoGuide.seoRules.filter((rule) => ruleIds.includes(rule.id))
|
|
8
|
+
: seoGuide.seoRules;
|
|
9
|
+
const knownFramework = ["nextjs-app", "nextjs-pages", "react-vite", "react-router", "remix"].includes(framework);
|
|
10
|
+
const implementation = knownFramework
|
|
11
|
+
? seoGuide.implementationSteps.frameworkSpecific[framework]
|
|
12
|
+
: framework === "auto"
|
|
13
|
+
? seoGuide.implementationSteps.frameworkSpecific
|
|
14
|
+
: seoGuide.implementationSteps.frameworkSpecific.other;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
title: seoGuide.title,
|
|
18
|
+
intro: seoGuide.intro,
|
|
19
|
+
seoRules: selectedRules,
|
|
20
|
+
implementationSteps: seoGuide.implementationSteps.steps,
|
|
21
|
+
framework: {
|
|
22
|
+
requested: framework,
|
|
23
|
+
implementation,
|
|
24
|
+
assistantGuidance: seoGuide.implementationSteps.assistantGuidance,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerNeuctraUiSeoSetupTool(server, { seoGuide }) {
|
|
30
|
+
server.registerTool(
|
|
31
|
+
"neuctra-ui-seo-setup",
|
|
32
|
+
{
|
|
33
|
+
title: "Neuctra UI SEO rules and implementation guide",
|
|
34
|
+
description:
|
|
35
|
+
"Return one complete SEO, AEO, and GEO guide for a Neuctra UI project. The guide has two portions: comprehensive SEO rules and ordered implementation steps, including framework-specific metadata, rendering, JSON-LD, sitemap, and robots guidance. This is an assistant reference tool: inspect the project and apply the rules to its actual framework; it does not generate a single page snippet or audit input.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
framework: z
|
|
38
|
+
.enum(["auto", ...FRAMEWORKS, "remix"])
|
|
39
|
+
.default("auto")
|
|
40
|
+
.describe("Project framework. Use auto to inspect repository context and infer it; framework changes how metadata and rendering are implemented."),
|
|
41
|
+
projectContext: z
|
|
42
|
+
.string()
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Optional package.json/framework config or brief project details to help select the right implementation steps."),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
async ({ framework, projectContext }) => {
|
|
48
|
+
const response = buildSeoGuideContext(seoGuide, { framework });
|
|
49
|
+
if (!response) {
|
|
50
|
+
return {
|
|
51
|
+
isError: true,
|
|
52
|
+
content: [{ type: "text", text: "SEO guide data was not loaded into this server." }],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
response.framework.projectContext = projectContext ?? null;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const FRAMEWORKS = ["nextjs", "vite", "remix", "react-router"];
|
|
6
|
+
const ignored = new Set(["node_modules", ".git", ".next", "dist", "build"]);
|
|
7
|
+
|
|
8
|
+
async function walk(dir, depth = 0) {
|
|
9
|
+
if (depth > 4) return [];
|
|
10
|
+
let entries;
|
|
11
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
12
|
+
const files = [];
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (ignored.has(entry.name)) continue;
|
|
15
|
+
const full = path.join(dir, entry.name);
|
|
16
|
+
if (entry.isFile()) files.push(path.relative(dir, full));
|
|
17
|
+
else if (entry.isDirectory()) files.push(...(await walk(full, depth + 1)).map((f) => path.join(entry.name, f)));
|
|
18
|
+
}
|
|
19
|
+
return files;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function analyze(projectPath) {
|
|
23
|
+
const files = await walk(projectPath);
|
|
24
|
+
let pkg = {};
|
|
25
|
+
try { pkg = JSON.parse(await fs.readFile(path.join(projectPath, "package.json"), "utf8")); } catch {}
|
|
26
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
27
|
+
const scripts = Object.values(pkg.scripts ?? {}).join(" ");
|
|
28
|
+
let framework = null;
|
|
29
|
+
if (deps.next || files.some((f) => /(^|\/)next\.config\./.test(f))) framework = "nextjs";
|
|
30
|
+
else if (deps["@remix-run/react"] || deps["@remix-run/dev"]) framework = "remix";
|
|
31
|
+
else if (deps["react-router"] || deps["@react-router/dev"] || /react-router/.test(scripts)) framework = "react-router";
|
|
32
|
+
else if (deps.vite && deps.react) framework = "vite";
|
|
33
|
+
const rootItems = await fs.readdir(projectPath).catch(() => []);
|
|
34
|
+
return {
|
|
35
|
+
projectPath,
|
|
36
|
+
framework,
|
|
37
|
+
packageName: pkg.name ?? null,
|
|
38
|
+
hasPackageJson: Boolean(pkg.name || Object.keys(pkg).length),
|
|
39
|
+
filesFound: files.length,
|
|
40
|
+
rootItems: rootItems.filter((name) => name !== "node_modules" && name !== ".git").slice(0, 40),
|
|
41
|
+
state: framework ? "framework-detected" : files.length ? "unrecognized-or-partial-project" : "empty-directory",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function run(command, args, cwd) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const child = spawn(command, args, { cwd, stdio: "inherit", shell: process.platform === "win32" });
|
|
48
|
+
child.once("error", reject);
|
|
49
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`${command} exited with code ${code}`)));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function emptyDirectory(projectPath) {
|
|
54
|
+
const resolved = path.resolve(projectPath);
|
|
55
|
+
if (resolved === path.parse(resolved).root || resolved === process.cwd()) {
|
|
56
|
+
throw new Error("Refusing to clear a filesystem root or the MCP server working directory.");
|
|
57
|
+
}
|
|
58
|
+
for (const entry of await fs.readdir(resolved, { withFileTypes: true })) {
|
|
59
|
+
await fs.rm(path.join(resolved, entry.name), { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function createFramework(projectPath, framework) {
|
|
64
|
+
if (framework === "nextjs") {
|
|
65
|
+
await run("npx", ["create-next-app@latest", ".", "--js", "--tailwind", "--app", "--src-dir", "--use-npm", "--yes"], projectPath);
|
|
66
|
+
const config = path.join(projectPath, "next.config.mjs");
|
|
67
|
+
await fs.writeFile(config, 'const nextConfig = { output: "export" };\n\nexport default nextConfig;\n');
|
|
68
|
+
} else if (framework === "vite") {
|
|
69
|
+
await run("npm", ["create", "vite@latest", ".", "--", "--template", "react"], projectPath);
|
|
70
|
+
await run("npm", ["install"], projectPath);
|
|
71
|
+
} else if (framework === "remix") {
|
|
72
|
+
await run("npx", ["create-remix@latest", ".", "--template", "remix-run/remix/templates/spa"], projectPath);
|
|
73
|
+
} else {
|
|
74
|
+
await run("npx", ["create-react-router@latest", "."], projectPath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Analyze first; setup actions require a subsequent explicit call with framework and choices. */
|
|
79
|
+
export async function setupNeuctraUi(input) {
|
|
80
|
+
const projectPath = path.resolve(input.projectPath || process.cwd());
|
|
81
|
+
const report = await analyze(projectPath);
|
|
82
|
+
if (input.action !== "setup") {
|
|
83
|
+
return { ...report, nextStep: report.framework ? "Choose whether to use CLI setup or manual setup." : "Choose a framework; if clearing is required, explicitly confirm it." };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const framework = input.framework ?? report.framework ?? "nextjs";
|
|
87
|
+
if (!FRAMEWORKS.includes(framework)) {
|
|
88
|
+
return { ...report, needsInput: "framework", allowedFrameworks: FRAMEWORKS, message: "Select a supported framework before setup can continue." };
|
|
89
|
+
}
|
|
90
|
+
if (!input.setupMode) {
|
|
91
|
+
return { ...report, selectedFramework: framework, needsInput: "setupMode", allowedSetupModes: ["cli", "manual"], message: "Choose whether to run the Neuctra UI CLI setup or get manual setup instructions." };
|
|
92
|
+
}
|
|
93
|
+
const setupMode = input.setupMode;
|
|
94
|
+
if (!["cli", "manual"].includes(setupMode)) throw new Error("setupMode must be cli or manual.");
|
|
95
|
+
if (setupMode === "manual") {
|
|
96
|
+
return { ...report, selectedFramework: framework, setupMode, instructions: ["Install @neuctra/ui.", "Configure Tailwind CSS v4 for the selected framework.", "Add @import \"tailwindcss\" and @source \"../node_modules/@neuctra/ui\" to global CSS.", "Run npx @neuctra/ui-cli@latest init to generate theme setup.", "Install lucide-react."] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const projectExists = report.filesFound > 0;
|
|
100
|
+
if (report.framework && report.framework !== framework && !input.confirmReplace) {
|
|
101
|
+
return { ...report, needsInput: "confirmReplace", message: `Detected ${report.framework}. Confirm replacing this project before selecting ${framework}.` };
|
|
102
|
+
}
|
|
103
|
+
const replaceProject = projectExists && (!report.framework || report.framework !== framework);
|
|
104
|
+
if (replaceProject && !input.confirmClear) {
|
|
105
|
+
return { ...report, needsInput: "confirmClear", message: "This directory contains files. Explicitly confirm clearing it to initialize a new framework." };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (replaceProject) await emptyDirectory(projectPath);
|
|
109
|
+
await fs.mkdir(projectPath, { recursive: true });
|
|
110
|
+
if (!report.framework || replaceProject) await createFramework(projectPath, framework);
|
|
111
|
+
await run("npx", ["@neuctra/ui-cli@latest", "init"], projectPath);
|
|
112
|
+
await run("npm", ["install", "lucide-react"], projectPath);
|
|
113
|
+
return { ...(await analyze(projectPath)), setupMode, setupComplete: true, installed: ["@neuctra/ui", "lucide-react"], themeInitializedBy: "@neuctra/ui-cli init", staticExport: framework === "nextjs" };
|
|
114
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { setupNeuctraUi } from "./index.mjs";
|
|
3
|
+
|
|
4
|
+
export function registerNeuctraUiSetupTool(server) {
|
|
5
|
+
server.registerTool(
|
|
6
|
+
"neuctra-ui-setup",
|
|
7
|
+
{
|
|
8
|
+
title: "Analyze or set up a Neuctra UI project",
|
|
9
|
+
description: "Dedicated project setup assistant for @neuctra/ui. It first analyzes the target directory and detects Next.js, Vite React, Remix, or React Router. Call with action=analyze first. Setup calls require an explicit framework and either cli (scaffold/configure using @neuctra/ui-cli, then install lucide-react) or manual (return step-by-step instructions). Existing matching projects are preserved and initialized in place. Replacing or clearing populated directories requires explicit confirmation flags; this tool never cleans a directory during analysis.",
|
|
10
|
+
inputSchema: {
|
|
11
|
+
action: z.enum(["analyze", "setup"]).default("analyze").describe("Always analyze before setup; setup performs the selected workflow."),
|
|
12
|
+
projectPath: z.string().optional().describe("Project directory; defaults to the MCP client's current working directory."),
|
|
13
|
+
framework: z.enum(["nextjs", "vite", "remix", "react-router"]).optional().describe("Framework selected by the user. With no preference, the recommended default is Next.js App Router, JavaScript, Tailwind, and static export."),
|
|
14
|
+
setupMode: z.enum(["cli", "manual"]).optional().describe("Use the setup CLI workflow, or return manual setup instructions."),
|
|
15
|
+
confirmClear: z.boolean().optional().describe("Explicit confirmation to remove files from a populated unrecognized directory before scaffolding."),
|
|
16
|
+
confirmReplace: z.boolean().optional().describe("Explicit confirmation to replace an existing detected framework with a different one."),
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
async (input) => {
|
|
20
|
+
try {
|
|
21
|
+
const result = await setupNeuctraUi(input);
|
|
22
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return { isError: true, content: [{ type: "text", text: `Neuctra UI setup failed: ${error.message}` }] };
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function registerNeuctraUiThemeSetupTool(server, { theme }) {
|
|
2
|
+
server.registerTool(
|
|
3
|
+
"neuctra-ui-theme-setup",
|
|
4
|
+
{
|
|
5
|
+
title: "Get Neuctra UI theme setup",
|
|
6
|
+
description:
|
|
7
|
+
"Return the Neuctra UI theme setup configuration: semantic color tokens, Tailwind CSS theme mappings, and theme initialization guidance. Use this to configure the theme system; use neuctra-ui-setup to run the CLI initializer in a project.",
|
|
8
|
+
inputSchema: {},
|
|
9
|
+
},
|
|
10
|
+
async () => {
|
|
11
|
+
const { rules, antiAiLookRules, ...themeSetup } = theme;
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: "text", text: JSON.stringify(themeSetup, null, 2) }],
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
);
|
|
17
|
+
}
|