@dowel-ui/mcp 0.7.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/LICENSE +21 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1076 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
//#region src/branding.ts
|
|
9
|
+
/**
|
|
10
|
+
* Branding, mirrored from the repository root config.
|
|
11
|
+
*
|
|
12
|
+
* Duplicated deliberately: the published server cannot import from the monorepo
|
|
13
|
+
* root, and `pnpm rebrand` rewrites every copy in the same pass.
|
|
14
|
+
*/
|
|
15
|
+
const branding = {
|
|
16
|
+
libraryName: "Dowel",
|
|
17
|
+
cliPackage: "@dowel-ui/cli",
|
|
18
|
+
packageScope: "@dowel-ui",
|
|
19
|
+
registryUrl: "https://dowel-eight.vercel.app/r"
|
|
20
|
+
};
|
|
21
|
+
//#endregion
|
|
22
|
+
//#region ../registry/dist/generate.js
|
|
23
|
+
/**
|
|
24
|
+
* Turning a description of a screen into a plan made of components that exist.
|
|
25
|
+
*
|
|
26
|
+
* The hard part of generating UI is not writing JSX. It is not inventing: a
|
|
27
|
+
* model asked for a billing page will cheerfully produce `<PricingTable>` and
|
|
28
|
+
* `<InvoiceList>` and a `variant="subtle"` that was never implemented, and the
|
|
29
|
+
* result reads perfectly and compiles nowhere.
|
|
30
|
+
*
|
|
31
|
+
* So this resolves a prompt against the registry first, and everything it
|
|
32
|
+
* emits afterwards is drawn from what came back. It cannot name a component
|
|
33
|
+
* that is not installable, because it only ever repeats names the registry
|
|
34
|
+
* gave it.
|
|
35
|
+
*
|
|
36
|
+
* It does not guess at props. The registry publishes what a component *is* and
|
|
37
|
+
* what it depends on, not the shape of its arguments, so the output stops at
|
|
38
|
+
* the composition and points at the page where the props are documented.
|
|
39
|
+
* Emitting a plausible prop is worse than emitting none — one is a gap, the
|
|
40
|
+
* other is a bug that looks like working code.
|
|
41
|
+
*/
|
|
42
|
+
/** Words that carry no signal about which component is wanted. */
|
|
43
|
+
const STOPWORDS = /* @__PURE__ */ new Set([
|
|
44
|
+
"a",
|
|
45
|
+
"an",
|
|
46
|
+
"the",
|
|
47
|
+
"and",
|
|
48
|
+
"or",
|
|
49
|
+
"of",
|
|
50
|
+
"for",
|
|
51
|
+
"with",
|
|
52
|
+
"to",
|
|
53
|
+
"in",
|
|
54
|
+
"on",
|
|
55
|
+
"at",
|
|
56
|
+
"by",
|
|
57
|
+
"is",
|
|
58
|
+
"are",
|
|
59
|
+
"be",
|
|
60
|
+
"make",
|
|
61
|
+
"build",
|
|
62
|
+
"create",
|
|
63
|
+
"want",
|
|
64
|
+
"need",
|
|
65
|
+
"add",
|
|
66
|
+
"show",
|
|
67
|
+
"page",
|
|
68
|
+
"screen",
|
|
69
|
+
"app",
|
|
70
|
+
"application",
|
|
71
|
+
"ui",
|
|
72
|
+
"interface",
|
|
73
|
+
"component",
|
|
74
|
+
"components",
|
|
75
|
+
"me",
|
|
76
|
+
"my",
|
|
77
|
+
"our",
|
|
78
|
+
"i",
|
|
79
|
+
"it",
|
|
80
|
+
"that",
|
|
81
|
+
"this",
|
|
82
|
+
"some",
|
|
83
|
+
"using",
|
|
84
|
+
"use",
|
|
85
|
+
"like"
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Words a reader would use that are not the words the registry uses.
|
|
89
|
+
*
|
|
90
|
+
* Hand-written, and deliberately so. "Sign in" is what someone types and
|
|
91
|
+
* `login` is what the item is called; no amount of string similarity bridges
|
|
92
|
+
* that, and pretending otherwise produces a matcher that works on the examples
|
|
93
|
+
* it was tuned against and nothing else.
|
|
94
|
+
*/
|
|
95
|
+
const SYNONYMS = {
|
|
96
|
+
"sign in": ["login"],
|
|
97
|
+
signin: ["login"],
|
|
98
|
+
"log in": ["login"],
|
|
99
|
+
"sign up": ["signup"],
|
|
100
|
+
register: ["signup"],
|
|
101
|
+
registration: ["signup"],
|
|
102
|
+
"forgot password": ["forgot-password"],
|
|
103
|
+
"reset password": ["forgot-password"],
|
|
104
|
+
chat: [
|
|
105
|
+
"ai-chat",
|
|
106
|
+
"ai-conversation",
|
|
107
|
+
"ai-prompt-input"
|
|
108
|
+
],
|
|
109
|
+
conversation: ["ai-chat"],
|
|
110
|
+
assistant: ["ai-chat"],
|
|
111
|
+
copilot: ["ai-chat"],
|
|
112
|
+
agent: [
|
|
113
|
+
"agent-console",
|
|
114
|
+
"ai-agent-status",
|
|
115
|
+
"ai-agent-plan"
|
|
116
|
+
],
|
|
117
|
+
agents: ["agent-console"],
|
|
118
|
+
tool: ["ai-tool"],
|
|
119
|
+
approval: ["ai-approval-request"],
|
|
120
|
+
approve: ["ai-approval-request"],
|
|
121
|
+
undo: ["ai-action-ledger"],
|
|
122
|
+
audit: ["ai-action-ledger", "activity-feed"],
|
|
123
|
+
tokens: ["ai-token-usage"],
|
|
124
|
+
spend: ["ai-dashboard", "billing"],
|
|
125
|
+
cost: ["ai-dashboard", "billing"],
|
|
126
|
+
usage: ["ai-dashboard", "billing"],
|
|
127
|
+
subscription: ["billing", "pricing"],
|
|
128
|
+
invoice: ["billing"],
|
|
129
|
+
invoices: ["billing"],
|
|
130
|
+
payment: ["billing"],
|
|
131
|
+
plan: ["pricing", "billing"],
|
|
132
|
+
plans: ["pricing"],
|
|
133
|
+
metrics: [
|
|
134
|
+
"analytics",
|
|
135
|
+
"dashboard",
|
|
136
|
+
"metric-delta"
|
|
137
|
+
],
|
|
138
|
+
chart: ["analytics"],
|
|
139
|
+
charts: ["analytics"],
|
|
140
|
+
graph: ["analytics"],
|
|
141
|
+
stats: ["dashboard", "analytics"],
|
|
142
|
+
overview: ["dashboard"],
|
|
143
|
+
grid: ["data-table", "table"],
|
|
144
|
+
spreadsheet: ["data-table"],
|
|
145
|
+
list: ["table", "data-table"],
|
|
146
|
+
search: ["command", "combobox"],
|
|
147
|
+
palette: ["command"],
|
|
148
|
+
shortcut: ["command", "shortcut-recorder"],
|
|
149
|
+
modal: ["dialog"],
|
|
150
|
+
popup: ["dialog", "popover"],
|
|
151
|
+
dropdown: ["dropdown-menu", "select"],
|
|
152
|
+
toast: ["toast"],
|
|
153
|
+
notification: ["toast", "activity-feed"],
|
|
154
|
+
notifications: ["toast", "settings"],
|
|
155
|
+
upload: ["file-upload"],
|
|
156
|
+
file: ["file-upload"],
|
|
157
|
+
date: ["date-picker", "calendar"],
|
|
158
|
+
time: ["time-range-picker"],
|
|
159
|
+
schedule: ["cron-editor"],
|
|
160
|
+
cron: ["cron-editor"],
|
|
161
|
+
team: ["admin-users", "settings"],
|
|
162
|
+
members: ["admin-users"],
|
|
163
|
+
users: ["admin-users"],
|
|
164
|
+
admin: ["admin-users"],
|
|
165
|
+
permissions: ["permission-matrix"],
|
|
166
|
+
roles: ["permission-matrix"],
|
|
167
|
+
profile: ["settings"],
|
|
168
|
+
preferences: ["settings"],
|
|
169
|
+
account: ["settings", "billing"],
|
|
170
|
+
setup: ["onboarding"],
|
|
171
|
+
checklist: ["onboarding"],
|
|
172
|
+
wizard: ["onboarding"],
|
|
173
|
+
logs: ["log-viewer"],
|
|
174
|
+
log: ["log-viewer"],
|
|
175
|
+
diff: ["diff-viewer", "record-diff"],
|
|
176
|
+
secret: ["secret-field"],
|
|
177
|
+
"api key": ["secret-field"],
|
|
178
|
+
key: ["secret-field"],
|
|
179
|
+
dns: ["dns-record"]
|
|
180
|
+
};
|
|
181
|
+
function normalise(prompt) {
|
|
182
|
+
return prompt.toLowerCase().replace(/[^a-z0-9\s-]/g, " ");
|
|
183
|
+
}
|
|
184
|
+
function words(prompt) {
|
|
185
|
+
return normalise(prompt).split(/\s+/).filter((word) => word.length > 2 && !STOPWORDS.has(word));
|
|
186
|
+
}
|
|
187
|
+
function synonymHits(prompt) {
|
|
188
|
+
const text = normalise(prompt);
|
|
189
|
+
const hits = /* @__PURE__ */ new Map();
|
|
190
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
191
|
+
for (const [phrase, names] of Object.entries(SYNONYMS)) {
|
|
192
|
+
if (!new RegExp(`(^|\\s)${phrase.replace(/\s+/g, "\\s+")}(\\s|$)`).test(text)) continue;
|
|
193
|
+
for (const word of phrase.split(/\s+/)) consumed.add(word);
|
|
194
|
+
for (const name of names) if (!hits.has(name)) hits.set(name, phrase);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
hits,
|
|
198
|
+
consumed
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Below this a match is coincidence rather than intent. */
|
|
202
|
+
const MINIMUM_SCORE = 20;
|
|
203
|
+
function scoreEntry(entry, terms, synonyms) {
|
|
204
|
+
const name = entry.name.toLowerCase();
|
|
205
|
+
const title = entry.title.toLowerCase();
|
|
206
|
+
const description = entry.description.toLowerCase();
|
|
207
|
+
let score = 0;
|
|
208
|
+
const reasons = [];
|
|
209
|
+
const synonym = synonyms.get(entry.name);
|
|
210
|
+
if (synonym) {
|
|
211
|
+
score += 60;
|
|
212
|
+
reasons.push(`"${synonym}"`);
|
|
213
|
+
}
|
|
214
|
+
for (const term of terms) if (name === term) {
|
|
215
|
+
score += 50;
|
|
216
|
+
reasons.push(`named "${term}"`);
|
|
217
|
+
} else if (name.split("-").includes(term)) {
|
|
218
|
+
score += 30;
|
|
219
|
+
reasons.push(`"${term}" in its name`);
|
|
220
|
+
} else if (title.includes(term)) {
|
|
221
|
+
score += 20;
|
|
222
|
+
reasons.push(`"${term}" in its title`);
|
|
223
|
+
} else if (entry.category === term) {
|
|
224
|
+
score += 12;
|
|
225
|
+
reasons.push(`the ${term} category`);
|
|
226
|
+
} else if (description.includes(term)) {
|
|
227
|
+
score += 8;
|
|
228
|
+
reasons.push(`"${term}" in its description`);
|
|
229
|
+
}
|
|
230
|
+
if (score === 0) return void 0;
|
|
231
|
+
const rank = score + (entry.type === "registry:block" ? 25 : 0);
|
|
232
|
+
return {
|
|
233
|
+
entry,
|
|
234
|
+
score,
|
|
235
|
+
rank,
|
|
236
|
+
because: [...new Set(reasons)].slice(0, 3).join(", ")
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function planUi(prompt, index, options = {}) {
|
|
240
|
+
const { maxBlocks = 3, maxComponents = 6 } = options;
|
|
241
|
+
const { hits: synonyms, consumed } = synonymHits(prompt);
|
|
242
|
+
const terms = words(prompt).filter((term) => !consumed.has(term));
|
|
243
|
+
const scored = index.items.filter((entry) => entry.type === "registry:ui" || entry.type === "registry:block").map((entry) => scoreEntry(entry, terms, synonyms)).filter((candidate) => candidate !== void 0).filter((candidate) => candidate.score >= MINIMUM_SCORE).sort((a, b) => b.rank - a.rank || a.entry.name.localeCompare(b.entry.name));
|
|
244
|
+
const blocks = scored.filter((candidate) => candidate.entry.type === "registry:block").slice(0, maxBlocks);
|
|
245
|
+
const covered = new Set(blocks.flatMap((candidate) => [candidate.entry.name, ...candidate.entry.registryDependencies]));
|
|
246
|
+
const components = scored.filter((candidate) => candidate.entry.type === "registry:ui" && !covered.has(candidate.entry.name)).slice(0, maxComponents);
|
|
247
|
+
const toEntry = (candidate) => ({
|
|
248
|
+
entry: candidate.entry,
|
|
249
|
+
because: candidate.because
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
prompt,
|
|
253
|
+
blocks: blocks.map(toEntry),
|
|
254
|
+
components: components.map(toEntry),
|
|
255
|
+
install: [...blocks, ...components].map((candidate) => candidate.entry.name),
|
|
256
|
+
empty: blocks.length === 0 && components.length === 0
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/** PascalCase export name for a registry name, e.g. "ai-tool" -> "AiTool". */
|
|
260
|
+
function tag(name) {
|
|
261
|
+
return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
262
|
+
}
|
|
263
|
+
/** Blocks export a `…Block` component; components export their own name. */
|
|
264
|
+
function componentName(entry) {
|
|
265
|
+
const base = tag(entry.name);
|
|
266
|
+
return entry.type === "registry:block" ? `${base}Block` : base;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Where an installed block lives.
|
|
270
|
+
*
|
|
271
|
+
* A project alias like `@/components/ui` has a sibling `@/components/blocks`. A
|
|
272
|
+
* bare package specifier has no block path at all, so the conventional install
|
|
273
|
+
* location is used instead of inventing one under the package.
|
|
274
|
+
*/
|
|
275
|
+
function blocksPathFor(importFrom) {
|
|
276
|
+
if (importFrom.endsWith("/ui")) return `${importFrom.slice(0, -3)}/blocks`;
|
|
277
|
+
if (importFrom.startsWith("@/") || importFrom.startsWith("~/")) return `${importFrom}/blocks`;
|
|
278
|
+
return "@/components/blocks";
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The plan as a starting file.
|
|
282
|
+
*
|
|
283
|
+
* Imports and composition only. Every element carries the page its props are
|
|
284
|
+
* documented on, because the registry does not publish prop shapes and a
|
|
285
|
+
* plausible invented prop is worse than an obvious gap — one is a TODO, the
|
|
286
|
+
* other is a bug wearing the costume of working code.
|
|
287
|
+
*/
|
|
288
|
+
function renderPlan(plan, options = {}) {
|
|
289
|
+
const { importFrom = "@/components/ui", docsUrl = "https://dowel-eight.vercel.app", blocksImportFrom = blocksPathFor(importFrom) } = options;
|
|
290
|
+
if (plan.empty) return `// Nothing in the registry matched "${plan.prompt}".\n`;
|
|
291
|
+
const chosen = [...plan.blocks, ...plan.components];
|
|
292
|
+
const imports = chosen.map((item) => {
|
|
293
|
+
const from = item.entry.type === "registry:block" ? `${blocksImportFrom}/${item.entry.name}` : `${importFrom}/${item.entry.name}`;
|
|
294
|
+
return `import { ${componentName(item.entry)} } from "${from}";`;
|
|
295
|
+
}).sort();
|
|
296
|
+
const body = chosen.map((item) => {
|
|
297
|
+
const name = componentName(item.entry);
|
|
298
|
+
return [` {/* ${item.entry.title} — props: ${docsUrl}/docs/${item.entry.type === "registry:block" ? "blocks" : "components"}/${item.entry.name} */}`, ` <${name} />`].join("\n");
|
|
299
|
+
}).join("\n\n");
|
|
300
|
+
return `${imports.join("\n")}
|
|
301
|
+
|
|
302
|
+
export default function Page() {
|
|
303
|
+
return (
|
|
304
|
+
<div className="flex flex-col gap-6">
|
|
305
|
+
${body}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
`;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The plan as a brief for a coding agent.
|
|
313
|
+
*
|
|
314
|
+
* The most useful thing this can produce. The agent has the project, the
|
|
315
|
+
* editor and the ability to write the props; what it lacks is the knowledge
|
|
316
|
+
* that these components exist and that it must not invent others. That is
|
|
317
|
+
* exactly what a grounded plan supplies.
|
|
318
|
+
*/
|
|
319
|
+
function renderBrief(plan, options = {}) {
|
|
320
|
+
const { cliPackage = "@dowel-ui/cli", docsUrl = "https://dowel-eight.vercel.app", importFrom = "@/components/ui" } = options;
|
|
321
|
+
if (plan.empty) return `Nothing in the registry matched "${plan.prompt}". Search the catalogue at ${docsUrl}/docs/components before building anything by hand.`;
|
|
322
|
+
const lines = [
|
|
323
|
+
`Build: ${plan.prompt}`,
|
|
324
|
+
"",
|
|
325
|
+
"Use these, which are already in the registry. Do not write your own versions,",
|
|
326
|
+
"and do not use any component not listed here without checking the catalogue first.",
|
|
327
|
+
""
|
|
328
|
+
];
|
|
329
|
+
if (plan.blocks.length > 0) {
|
|
330
|
+
lines.push("Blocks (whole sections — each installs its own components):");
|
|
331
|
+
for (const item of plan.blocks) lines.push(`- ${item.entry.name} — ${item.entry.description}`);
|
|
332
|
+
lines.push("");
|
|
333
|
+
}
|
|
334
|
+
if (plan.components.length > 0) {
|
|
335
|
+
lines.push("Components:");
|
|
336
|
+
for (const item of plan.components) lines.push(`- ${item.entry.name} — ${item.entry.description}`);
|
|
337
|
+
lines.push("");
|
|
338
|
+
}
|
|
339
|
+
lines.push("Install first:", "", ` npx ${cliPackage} add ${plan.install.join(" ")}`, "", `Import from \`${importFrom}\`. Each component's props are on its page under`, `${docsUrl}/docs/components — read the page rather than guessing a prop name.`, "", "Style with semantic tokens only (bg-background, text-muted-foreground). Never raw", "hex, never Tailwind's own palette — those do not follow the theme.");
|
|
340
|
+
return lines.join("\n");
|
|
341
|
+
}
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region ../registry/dist/schema-BbyNjH6Q.js
|
|
344
|
+
const registryFileTypeSchema = z.enum([
|
|
345
|
+
"registry:ui",
|
|
346
|
+
"registry:lib",
|
|
347
|
+
"registry:hook",
|
|
348
|
+
"registry:block",
|
|
349
|
+
"registry:style"
|
|
350
|
+
]);
|
|
351
|
+
const registryItemTypeSchema = z.enum([
|
|
352
|
+
"registry:ui",
|
|
353
|
+
"registry:lib",
|
|
354
|
+
"registry:hook",
|
|
355
|
+
"registry:theme",
|
|
356
|
+
"registry:block"
|
|
357
|
+
]);
|
|
358
|
+
/**
|
|
359
|
+
* Whether an item's source is public.
|
|
360
|
+
*
|
|
361
|
+
* `free` is the default and stays the default: an item that has ever been
|
|
362
|
+
* installable without a licence must never quietly become one that is not.
|
|
363
|
+
* Existing registries carry no `access` field at all, which parses as `free` —
|
|
364
|
+
* so an older registry read by a newer CLI behaves exactly as it did.
|
|
365
|
+
*/
|
|
366
|
+
const registryAccessSchema = z.enum(["free", "pro"]).default("free");
|
|
367
|
+
const registryFileSchema = z.object({
|
|
368
|
+
/**
|
|
369
|
+
* Logical path within the registry, e.g. `ui/button.tsx`, `lib/utils.ts`.
|
|
370
|
+
*
|
|
371
|
+
* The leading segment selects which of the consumer's aliases the file is
|
|
372
|
+
* written under. The registry deliberately does not know the destination —
|
|
373
|
+
* that depends on a project layout it has never seen.
|
|
374
|
+
*/
|
|
375
|
+
path: z.string().min(1),
|
|
376
|
+
type: registryFileTypeSchema,
|
|
377
|
+
content: z.string(),
|
|
378
|
+
/**
|
|
379
|
+
* `sha256:<hex>` of `content` as published.
|
|
380
|
+
*
|
|
381
|
+
* Recorded at install time so `update` can tell an untouched file from one
|
|
382
|
+
* the user has edited. This cannot be added later: an install that did not
|
|
383
|
+
* record a hash leaves no way to know what it originally wrote.
|
|
384
|
+
*/
|
|
385
|
+
hash: z.string().regex(/^sha256:[0-9a-f]{64}$/)
|
|
386
|
+
});
|
|
387
|
+
const registryItemSchema = z.object({
|
|
388
|
+
$schema: z.string().optional(),
|
|
389
|
+
registryVersion: z.literal(1),
|
|
390
|
+
name: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
391
|
+
type: registryItemTypeSchema,
|
|
392
|
+
title: z.string().min(1),
|
|
393
|
+
description: z.string().min(10),
|
|
394
|
+
category: z.string().min(1),
|
|
395
|
+
status: z.enum([
|
|
396
|
+
"stable",
|
|
397
|
+
"beta",
|
|
398
|
+
"experimental"
|
|
399
|
+
]),
|
|
400
|
+
/** npm packages to install alongside the files. */
|
|
401
|
+
dependencies: z.array(z.string()),
|
|
402
|
+
/** Other registry items to install first. */
|
|
403
|
+
registryDependencies: z.array(z.string()),
|
|
404
|
+
files: z.array(registryFileSchema).min(1),
|
|
405
|
+
a11y: z.string().optional(),
|
|
406
|
+
access: registryAccessSchema
|
|
407
|
+
});
|
|
408
|
+
/**
|
|
409
|
+
* The index entry.
|
|
410
|
+
*
|
|
411
|
+
* Carries `access` so a licensed item is *listed* — with its title, what it
|
|
412
|
+
* depends on and how many files it has — while its source is not. Hiding paid
|
|
413
|
+
* items entirely would mean nobody could discover them; including their source
|
|
414
|
+
* would mean nobody needed to buy them. The index is the catalogue; the item
|
|
415
|
+
* body is the goods.
|
|
416
|
+
*/
|
|
417
|
+
const registryIndexEntrySchema = registryItemSchema.pick({
|
|
418
|
+
name: true,
|
|
419
|
+
type: true,
|
|
420
|
+
title: true,
|
|
421
|
+
description: true,
|
|
422
|
+
category: true,
|
|
423
|
+
status: true,
|
|
424
|
+
dependencies: true,
|
|
425
|
+
registryDependencies: true,
|
|
426
|
+
access: true
|
|
427
|
+
}).extend({ fileCount: z.number().int().positive() });
|
|
428
|
+
const registryIndexSchema = z.object({
|
|
429
|
+
$schema: z.string().optional(),
|
|
430
|
+
registryVersion: z.literal(1),
|
|
431
|
+
/** Version of the package the registry was generated from. */
|
|
432
|
+
generatedFrom: z.string().min(1),
|
|
433
|
+
items: z.array(registryIndexEntrySchema)
|
|
434
|
+
});
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region ../registry/dist/index.js
|
|
437
|
+
/**
|
|
438
|
+
* Building a registry of your own components.
|
|
439
|
+
*
|
|
440
|
+
* The CLI has always been able to install from any registry — `--registry`
|
|
441
|
+
* takes a URL or a directory — but producing one meant reimplementing this
|
|
442
|
+
* package. So an organisation that wanted its own components installed the same
|
|
443
|
+
* way had the consumer half and none of the producer half.
|
|
444
|
+
*
|
|
445
|
+
* The authoring shape is declared here rather than imported from the component
|
|
446
|
+
* package, because the registry *is* the contract. A team publishing their own
|
|
447
|
+
* components should not have to depend on somebody else's component library to
|
|
448
|
+
* describe their own.
|
|
449
|
+
*/
|
|
450
|
+
/** Where an item's files are written in the consuming project. */
|
|
451
|
+
const itemGroupSchema = z.enum([
|
|
452
|
+
"ui",
|
|
453
|
+
"blocks",
|
|
454
|
+
"lib",
|
|
455
|
+
"hooks"
|
|
456
|
+
]);
|
|
457
|
+
const itemSourceSchema = z.object({
|
|
458
|
+
name: z.string().regex(/^[a-z][a-z0-9-]*$/),
|
|
459
|
+
title: z.string().min(1),
|
|
460
|
+
description: z.string().min(10),
|
|
461
|
+
category: z.string().min(1),
|
|
462
|
+
status: z.enum([
|
|
463
|
+
"stable",
|
|
464
|
+
"beta",
|
|
465
|
+
"experimental"
|
|
466
|
+
]).default("stable"),
|
|
467
|
+
/** Where the files land. Defaults to `ui`. */
|
|
468
|
+
group: itemGroupSchema.default("ui"),
|
|
469
|
+
/** npm packages the source imports. */
|
|
470
|
+
dependencies: z.array(z.string()).default([]),
|
|
471
|
+
/** Other registry items this one imports, upstream ones included. */
|
|
472
|
+
registryDependencies: z.array(z.string()).default([]),
|
|
473
|
+
/** Files to publish, relative to the item's own directory. */
|
|
474
|
+
files: z.array(z.string().min(1)).min(1),
|
|
475
|
+
a11y: z.string().optional(),
|
|
476
|
+
access: registryAccessSchema,
|
|
477
|
+
/**
|
|
478
|
+
* Overrides where the item's directory is, relative to the registry root.
|
|
479
|
+
* Defaults to `<group>/<name>`, which is the layout this repository uses.
|
|
480
|
+
*/
|
|
481
|
+
directory: z.string().optional()
|
|
482
|
+
});
|
|
483
|
+
z.object({
|
|
484
|
+
/** Absolute path the item directories are resolved against. */
|
|
485
|
+
root: z.string().min(1),
|
|
486
|
+
items: z.array(itemSourceSchema).min(1),
|
|
487
|
+
/**
|
|
488
|
+
* A registry to layer on top of — a URL, or a directory on disk.
|
|
489
|
+
*
|
|
490
|
+
* The reason a private registry is worth having at all: one URL that serves
|
|
491
|
+
* both the upstream components and yours, so a consumer configures one place
|
|
492
|
+
* and `add` resolves across both.
|
|
493
|
+
*/
|
|
494
|
+
extends: z.string().min(1).optional(),
|
|
495
|
+
/** Written into the index, so a consumer can see what produced it. */
|
|
496
|
+
generatedFrom: z.string().min(1).default("custom-registry")
|
|
497
|
+
});
|
|
498
|
+
const CATEGORY_LABELS = {
|
|
499
|
+
foundation: "Foundation",
|
|
500
|
+
form: "Forms",
|
|
501
|
+
overlay: "Overlays",
|
|
502
|
+
navigation: "Navigation",
|
|
503
|
+
display: "Display",
|
|
504
|
+
data: "Data",
|
|
505
|
+
feedback: "Feedback",
|
|
506
|
+
layout: "Layout",
|
|
507
|
+
ai: "AI"
|
|
508
|
+
};
|
|
509
|
+
const CATEGORY_ORDER = [
|
|
510
|
+
"foundation",
|
|
511
|
+
"form",
|
|
512
|
+
"overlay",
|
|
513
|
+
"navigation",
|
|
514
|
+
"display",
|
|
515
|
+
"data",
|
|
516
|
+
"feedback",
|
|
517
|
+
"layout",
|
|
518
|
+
"ai"
|
|
519
|
+
];
|
|
520
|
+
function label(category) {
|
|
521
|
+
return CATEGORY_LABELS[category] ?? category;
|
|
522
|
+
}
|
|
523
|
+
/** Ordered by curation where curated, alphabetical for anything new. */
|
|
524
|
+
function categoriesOf(entries) {
|
|
525
|
+
const present = new Set(entries.map((entry) => entry.category));
|
|
526
|
+
const known = CATEGORY_ORDER.filter((category) => present.has(category));
|
|
527
|
+
const rest = [...present].filter((category) => !CATEGORY_ORDER.includes(category)).sort();
|
|
528
|
+
return [...known, ...rest];
|
|
529
|
+
}
|
|
530
|
+
function byType(index, type) {
|
|
531
|
+
return index.items.filter((entry) => entry.type === type).sort((a, b) => a.name.localeCompare(b.name));
|
|
532
|
+
}
|
|
533
|
+
function components(index) {
|
|
534
|
+
return byType(index, "registry:ui");
|
|
535
|
+
}
|
|
536
|
+
function blocks(index) {
|
|
537
|
+
return byType(index, "registry:block");
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Accessibility rules that differ from what a model has seen elsewhere.
|
|
541
|
+
*
|
|
542
|
+
* An agent trained on every other React library will reach for `disabled` on a
|
|
543
|
+
* loading button and a live region on every alert. Stating only the deltas is
|
|
544
|
+
* deliberate: a general accessibility lecture is ignored, a short list of
|
|
545
|
+
* "here this is different" is followed.
|
|
546
|
+
*/
|
|
547
|
+
const ACCESSIBILITY_DELTAS = [
|
|
548
|
+
"A loading `Button` uses `aria-disabled` + `aria-busy` and guards its own click handler. Never add `disabled` to it — disabling a control mid-action strands keyboard focus.",
|
|
549
|
+
"`Alert` is not a live region by default. One that exists on first paint announces for no reason. Opt in with `live=\"polite\"` or `live=\"assertive\"` only when the alert appears in response to something.",
|
|
550
|
+
"`Separator`, `Skeleton` and `Spinner` are decorative and stay out of the accessibility tree unless given a label. Do not add `role` or `aria-label` to them by reflex.",
|
|
551
|
+
"`PopoverContent` carries `role=\"dialog\"` and warns in development without an accessible name. Always give it `aria-label` or `aria-labelledby`.",
|
|
552
|
+
"Never use colour as the only signal. The `monochrome` preset exists as a standing check on exactly this — if a state is unreadable under it, the component is wrong."
|
|
553
|
+
];
|
|
554
|
+
const TOKEN_RULES = [
|
|
555
|
+
"Use semantic tokens (`bg-background`, `text-foreground`, `border-border`, `ring-ring`, `bg-primary`, `text-muted-foreground`). Never raw hex, and never Tailwind's own palette (`bg-slate-900`, `text-gray-500`) — those do not follow the theme and break every preset and dark mode.",
|
|
556
|
+
"Spacing, radius and type come from the scale. `rounded-md` and `rounded-lg` re-proportion with `--radius-scale`; an arbitrary `rounded-[7px]` does not.",
|
|
557
|
+
"Durations derive from `--motion-scale`. Do not hardcode transition timings.",
|
|
558
|
+
"Compose class names with `cn()` from the project's utils, so consumer overrides win over defaults."
|
|
559
|
+
];
|
|
560
|
+
function conventionsDoc(context) {
|
|
561
|
+
const { libraryName, cliPackage, importFrom, docsUrl } = context;
|
|
562
|
+
return `# ${libraryName} — conventions
|
|
563
|
+
|
|
564
|
+
Rules for writing code in this project. ${libraryName} is **source-first**: its
|
|
565
|
+
components are files in this repository, not a dependency you can reason about
|
|
566
|
+
from its README. They are yours to edit, and edits are preserved across updates.
|
|
567
|
+
|
|
568
|
+
## The rule that matters most
|
|
569
|
+
|
|
570
|
+
**Do not hand-write a component that ${libraryName} already has.** Check the
|
|
571
|
+
catalogue in \`components.md\` first. Writing a second Button — with different
|
|
572
|
+
focus rings, different disabled semantics, different tokens — is the single
|
|
573
|
+
most common and most damaging thing to do here.
|
|
574
|
+
|
|
575
|
+
## Adding a component
|
|
576
|
+
|
|
577
|
+
\`\`\`bash
|
|
578
|
+
npx ${cliPackage} add <name>
|
|
579
|
+
\`\`\`
|
|
580
|
+
|
|
581
|
+
This writes the source into the project and installs whatever it depends on.
|
|
582
|
+
\`add\` is safe to re-run: an untouched file is left alone, an edited one is
|
|
583
|
+
never overwritten without \`--overwrite\`.
|
|
584
|
+
|
|
585
|
+
Do not \`npm install\` a component. Do not copy source out of the documentation
|
|
586
|
+
by hand — the CLI resolves the dependency graph and rewrites imports to this
|
|
587
|
+
project's path alias, and doing it manually gets both wrong.
|
|
588
|
+
|
|
589
|
+
## Importing
|
|
590
|
+
|
|
591
|
+
\`\`\`tsx
|
|
592
|
+
import { Button, Card, CardContent } from "${importFrom}";
|
|
593
|
+
\`\`\`
|
|
594
|
+
|
|
595
|
+
## Styling
|
|
596
|
+
|
|
597
|
+
${TOKEN_RULES.map((rule) => `- ${rule}`).join("\n")}
|
|
598
|
+
|
|
599
|
+
## Accessibility
|
|
600
|
+
|
|
601
|
+
Targeted at WCAG 2.2 AA, verified with axe per component. Where ${libraryName}
|
|
602
|
+
differs from what you have seen in other libraries:
|
|
603
|
+
|
|
604
|
+
${ACCESSIBILITY_DELTAS.map((rule) => `- ${rule}`).join("\n")}
|
|
605
|
+
|
|
606
|
+
## Before you build a page
|
|
607
|
+
|
|
608
|
+
Check \`components.md\` for a **block** that already covers it. A block is a
|
|
609
|
+
whole section — a login form, a settings page, a chat surface — and installing
|
|
610
|
+
one brings every component it is assembled from. Building a dashboard out of
|
|
611
|
+
individual primitives when \`add dashboard\` exists is wasted work.
|
|
612
|
+
|
|
613
|
+
## Reference
|
|
614
|
+
|
|
615
|
+
- Documentation: ${docsUrl}
|
|
616
|
+
- Full text for models: ${docsUrl}/llms-full.txt
|
|
617
|
+
`;
|
|
618
|
+
}
|
|
619
|
+
function componentsDoc(context) {
|
|
620
|
+
const { index, libraryName, cliPackage, installed } = context;
|
|
621
|
+
const have = new Set(installed ?? []);
|
|
622
|
+
const known = installed !== void 0;
|
|
623
|
+
const ui = components(index);
|
|
624
|
+
const blk = blocks(index);
|
|
625
|
+
const lines = [
|
|
626
|
+
`# ${libraryName} — catalogue`,
|
|
627
|
+
"",
|
|
628
|
+
`${String(ui.length)} components and ${String(blk.length)} blocks, generated from \`${index.generatedFrom}\`. This is the complete list — anything not here does not exist.`,
|
|
629
|
+
""
|
|
630
|
+
];
|
|
631
|
+
if (known) lines.push("`✓` marks what is already installed in this project. Everything else needs", `\`npx ${cliPackage} add <name>\` before it can be imported.`, "");
|
|
632
|
+
const mark = (entry) => known ? have.has(entry.name) ? "✓ " : " " : "";
|
|
633
|
+
for (const category of categoriesOf(ui)) {
|
|
634
|
+
lines.push(`## ${label(category)}`, "");
|
|
635
|
+
for (const entry of ui.filter((item) => item.category === category)) {
|
|
636
|
+
const status = entry.status === "stable" ? "" : ` _(${entry.status})_`;
|
|
637
|
+
lines.push(`- ${mark(entry)}**${entry.name}** — ${entry.description}${status}`);
|
|
638
|
+
}
|
|
639
|
+
lines.push("");
|
|
640
|
+
}
|
|
641
|
+
lines.push("## Blocks", "", "Whole sections. Installing one installs every component it is built from.", "");
|
|
642
|
+
for (const entry of blk) {
|
|
643
|
+
const deps = entry.registryDependencies.length;
|
|
644
|
+
const resolves = deps > 0 ? ` _(resolves ${String(deps)} components)_` : "";
|
|
645
|
+
lines.push(`- ${mark(entry)}**${entry.name}** — ${entry.description}${resolves}`);
|
|
646
|
+
}
|
|
647
|
+
lines.push("");
|
|
648
|
+
return lines.join("\n");
|
|
649
|
+
}
|
|
650
|
+
function aiDoc(context) {
|
|
651
|
+
const { index, libraryName, cliPackage } = context;
|
|
652
|
+
const ai = components(index).filter((entry) => entry.category === "ai");
|
|
653
|
+
return `# ${libraryName} — AI components
|
|
654
|
+
|
|
655
|
+
${String(ai.length)} surfaces for AI features. Reach for these before building
|
|
656
|
+
anything custom for a model-facing interface.
|
|
657
|
+
|
|
658
|
+
Most component sets ship a chat transcript and stop. Real AI features are
|
|
659
|
+
extraction, enrichment, autofill and agents that *change things* — so the parts
|
|
660
|
+
that matter are the ones around the transcript, not the transcript itself.
|
|
661
|
+
|
|
662
|
+
${ai.map((entry) => `- **${entry.name}** — ${entry.description}`).join("\n")}
|
|
663
|
+
|
|
664
|
+
## Choosing between them
|
|
665
|
+
|
|
666
|
+
- Rendering a conversation → \`ai-conversation\` with \`ai-message\` and \`ai-response\`.
|
|
667
|
+
- The composer → \`ai-prompt-input\`, with \`ai-model-selector\` if the model is switchable.
|
|
668
|
+
- A tool the model called → \`ai-tool\`. Its arguments and result belong there, not in prose.
|
|
669
|
+
- Asking permission *before* a tool runs → \`ai-approval-request\`.
|
|
670
|
+
- Reporting what it did *after* → \`ai-action-ledger\`, which is also where reversibility belongs. A deletion can be undone, a refund can only be offset, a sent email cannot be taken back — the ledger is where that distinction is shown.
|
|
671
|
+
- An object streaming in field by field → \`ai-structured-output\`, which reserves layout up front so nothing jumps.
|
|
672
|
+
- Ghost text in a real textarea → \`ai-inline-completion\`. Escape always returns Tab to focus management, so a keyboard user is never trapped.
|
|
673
|
+
- A value the model proposes for a form field → \`ai-suggested-value\`.
|
|
674
|
+
- Reviewing what was pulled out of a document → \`ai-extraction-review\`.
|
|
675
|
+
- Long-running work → \`ai-agent-status\` and \`ai-agent-plan\`.
|
|
676
|
+
- Where an answer came from → \`ai-sources\`. Cost → \`ai-token-usage\`. Chain of thought → \`ai-reasoning\`.
|
|
677
|
+
|
|
678
|
+
## Whole surface at once
|
|
679
|
+
|
|
680
|
+
\`\`\`bash
|
|
681
|
+
npx ${cliPackage} add ai-chat
|
|
682
|
+
\`\`\`
|
|
683
|
+
`;
|
|
684
|
+
}
|
|
685
|
+
function themesDoc(context) {
|
|
686
|
+
const { libraryName } = context;
|
|
687
|
+
return `# ${libraryName} — theming
|
|
688
|
+
|
|
689
|
+
Tokens are two-tier. **Tier 1** is raw scales: an OKLCH neutral ramp, a radius
|
|
690
|
+
ladder, a 15px-base type scale, elevation, motion. **Tier 2** is semantic
|
|
691
|
+
aliases — \`--primary\`, \`--background\`, \`--border\`, \`--ring\` — and components
|
|
692
|
+
consume Tier 2 *exclusively*.
|
|
693
|
+
|
|
694
|
+
Re-skinning the system means reassigning Tier 2. It never means editing a
|
|
695
|
+
component file. If you find yourself changing a colour inside a component, the
|
|
696
|
+
change belongs in the token layer instead.
|
|
697
|
+
|
|
698
|
+
## Presets
|
|
699
|
+
|
|
700
|
+
\`default\`, \`ocean\`, \`emerald\`, \`violet\`, \`rose\`, \`amber\`, \`monochrome\`.
|
|
701
|
+
|
|
702
|
+
\`\`\`html
|
|
703
|
+
<html data-theme="ocean" class="dark">
|
|
704
|
+
\`\`\`
|
|
705
|
+
|
|
706
|
+
\`data-theme\` selects the preset; the \`dark\` class selects the mode. They are
|
|
707
|
+
independent — every preset works in both.
|
|
708
|
+
|
|
709
|
+
\`monochrome\` is not only a style. It is a standing check that no component uses
|
|
710
|
+
colour as its only signal, so verify new work under it.
|
|
711
|
+
|
|
712
|
+
## Two properties that re-proportion everything
|
|
713
|
+
|
|
714
|
+
- \`--radius-scale\` — one multiplier behind every corner in the system. \`1\` is the designed default, \`0\` is fully square.
|
|
715
|
+
- \`--motion-scale\` — one multiplier every duration derives from. Under \`prefers-reduced-motion\` it collapses, but indicators that report ongoing state are *slowed* rather than stopped via \`--motion-scale-indicator\`, because a frozen spinner reads as a hung application.
|
|
716
|
+
|
|
717
|
+
## Contrast
|
|
718
|
+
|
|
719
|
+
All semantic pairs are verified against WCAG 2.2 AA across both modes and every
|
|
720
|
+
preset, in CI. A new token pair has to pass the same check — do not introduce
|
|
721
|
+
one without running \`audit:contrast\`.
|
|
722
|
+
`;
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/registry.ts
|
|
726
|
+
/**
|
|
727
|
+
* Reads the registry over HTTP, or from a directory on disk.
|
|
728
|
+
*
|
|
729
|
+
* A deliberate copy of the CLI's client rather than a shared module: this
|
|
730
|
+
* process is long-lived and answers many questions about the same registry, so
|
|
731
|
+
* it caches, while the CLI runs once and does not. Sharing the code would mean
|
|
732
|
+
* one of the two carrying machinery it does not want.
|
|
733
|
+
*/
|
|
734
|
+
var RegistryClient = class {
|
|
735
|
+
baseUrl;
|
|
736
|
+
#index;
|
|
737
|
+
#items = /* @__PURE__ */ new Map();
|
|
738
|
+
constructor(baseUrl) {
|
|
739
|
+
this.baseUrl = baseUrl;
|
|
740
|
+
}
|
|
741
|
+
get #isHttp() {
|
|
742
|
+
return this.baseUrl.startsWith("http://") || this.baseUrl.startsWith("https://");
|
|
743
|
+
}
|
|
744
|
+
async #readJson(file) {
|
|
745
|
+
if (!this.#isHttp) {
|
|
746
|
+
const root = this.baseUrl.startsWith("file:") ? fileURLToPath(this.baseUrl) : this.baseUrl;
|
|
747
|
+
const path = join(root, file);
|
|
748
|
+
if (!existsSync(path)) throw new Error(`Not found in the registry: ${file}`);
|
|
749
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
750
|
+
}
|
|
751
|
+
const url = `${this.baseUrl.replace(/\/$/, "")}/${file}`;
|
|
752
|
+
const response = await fetch(url);
|
|
753
|
+
if (response.status === 404) throw new Error(`Not found in the registry: ${file}`);
|
|
754
|
+
if (!response.ok) throw new Error(`Registry returned ${String(response.status)} for ${url}`);
|
|
755
|
+
return await response.json();
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Cached for the life of the process.
|
|
759
|
+
*
|
|
760
|
+
* The registry is immutable for a given release, and an agent asks about it
|
|
761
|
+
* dozens of times in a session; refetching would add latency to every tool
|
|
762
|
+
* call for data that cannot have changed.
|
|
763
|
+
*/
|
|
764
|
+
index() {
|
|
765
|
+
this.#index ??= this.#readJson("index.json").then((raw) => {
|
|
766
|
+
const parsed = registryIndexSchema.safeParse(raw);
|
|
767
|
+
if (!parsed.success) throw new Error("The registry index does not match the format this server understands. Update @dowel-ui/mcp, or point it at a compatible registry.");
|
|
768
|
+
return parsed.data;
|
|
769
|
+
});
|
|
770
|
+
return this.#index;
|
|
771
|
+
}
|
|
772
|
+
item(name) {
|
|
773
|
+
let cached = this.#items.get(name);
|
|
774
|
+
if (!cached) {
|
|
775
|
+
cached = this.#readJson(`${name}.json`).then((raw) => {
|
|
776
|
+
const parsed = registryItemSchema.safeParse(raw);
|
|
777
|
+
if (!parsed.success) throw new Error(`Registry entry "${name}" is malformed.`);
|
|
778
|
+
return parsed.data;
|
|
779
|
+
});
|
|
780
|
+
cached.catch(() => this.#items.delete(name));
|
|
781
|
+
this.#items.set(name, cached);
|
|
782
|
+
}
|
|
783
|
+
return cached;
|
|
784
|
+
}
|
|
785
|
+
/** Items and everything they depend on, dependencies first. */
|
|
786
|
+
async resolve(names) {
|
|
787
|
+
const ordered = [];
|
|
788
|
+
const placed = /* @__PURE__ */ new Set();
|
|
789
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
790
|
+
const visit = async (name) => {
|
|
791
|
+
if (placed.has(name) || visiting.has(name)) return;
|
|
792
|
+
visiting.add(name);
|
|
793
|
+
const item = await this.item(name);
|
|
794
|
+
for (const dependency of item.registryDependencies) await visit(dependency);
|
|
795
|
+
visiting.delete(name);
|
|
796
|
+
placed.add(name);
|
|
797
|
+
ordered.push(item);
|
|
798
|
+
};
|
|
799
|
+
for (const name of names) await visit(name);
|
|
800
|
+
return ordered;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region src/server.ts
|
|
805
|
+
function text(value) {
|
|
806
|
+
return { content: [{
|
|
807
|
+
type: "text",
|
|
808
|
+
text: value
|
|
809
|
+
}] };
|
|
810
|
+
}
|
|
811
|
+
function docsContext(options, index) {
|
|
812
|
+
return {
|
|
813
|
+
index,
|
|
814
|
+
registryUrl: options.registryUrl,
|
|
815
|
+
docsUrl: options.docsUrl,
|
|
816
|
+
cliPackage: options.cliPackage,
|
|
817
|
+
libraryName: options.libraryName,
|
|
818
|
+
importFrom: options.importFrom
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function summarise(entry) {
|
|
822
|
+
const kind = entry.type === "registry:block" ? "block" : "component";
|
|
823
|
+
const status = entry.status === "stable" ? "" : ` (${entry.status})`;
|
|
824
|
+
return `${entry.name} — ${kind}, ${entry.category}${status}\n ${entry.description}`;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Levenshtein distance, iterative with a single row.
|
|
828
|
+
*
|
|
829
|
+
* Only ever run against a name the caller got wrong, over a list of fewer than
|
|
830
|
+
* a hundred short strings, so the row-per-character allocation a clearer
|
|
831
|
+
* implementation would make is not worth avoiding — and the full matrix is not
|
|
832
|
+
* worth keeping, since only the distance is wanted.
|
|
833
|
+
*/
|
|
834
|
+
function distance(a, b) {
|
|
835
|
+
if (a === b) return 0;
|
|
836
|
+
if (a.length === 0) return b.length;
|
|
837
|
+
if (b.length === 0) return a.length;
|
|
838
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
839
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
840
|
+
const current = [i];
|
|
841
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
842
|
+
const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
843
|
+
const deletion = (previous[j] ?? 0) + 1;
|
|
844
|
+
const insertion = (current[j - 1] ?? 0) + 1;
|
|
845
|
+
current[j] = Math.min(substitution, deletion, insertion);
|
|
846
|
+
}
|
|
847
|
+
previous = current;
|
|
848
|
+
}
|
|
849
|
+
return previous[b.length] ?? 0;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Names closest to one that does not exist.
|
|
853
|
+
*
|
|
854
|
+
* Substring matching alone answers nothing for a typo — "datatabel" shares no
|
|
855
|
+
* run with "data-table" — and a typo is exactly the case where a suggestion is
|
|
856
|
+
* worth most, because the agent already knows what it wants. Hyphens are
|
|
857
|
+
* dropped before comparing so "datatable" reads as one edit from "data-table"
|
|
858
|
+
* rather than two.
|
|
859
|
+
*/
|
|
860
|
+
function nearest(names, query, limit = 3) {
|
|
861
|
+
const needle = query.toLowerCase().replace(/-/g, "");
|
|
862
|
+
return names.map((name) => ({
|
|
863
|
+
name,
|
|
864
|
+
gap: distance(name.toLowerCase().replace(/-/g, ""), needle)
|
|
865
|
+
})).filter(({ gap }) => gap <= Math.max(2, Math.floor(needle.length / 3))).sort((a, b) => a.gap - b.gap || a.name.localeCompare(b.name)).slice(0, limit).map(({ name }) => name);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Scores a query against one entry.
|
|
869
|
+
*
|
|
870
|
+
* Name matches outrank description matches because an agent that already knows
|
|
871
|
+
* roughly what a thing is called should get it first, and a word common to
|
|
872
|
+
* thirty descriptions should not bury the component actually named after it.
|
|
873
|
+
* Zero means no match, and no-match is excluded rather than ranked last.
|
|
874
|
+
*/
|
|
875
|
+
function score(entry, query) {
|
|
876
|
+
const needle = query.toLowerCase().trim();
|
|
877
|
+
if (needle.length === 0) return 1;
|
|
878
|
+
const name = entry.name.toLowerCase();
|
|
879
|
+
if (name === needle) return 100;
|
|
880
|
+
if (name.startsWith(needle)) return 50;
|
|
881
|
+
if (name.includes(needle)) return 25;
|
|
882
|
+
if (entry.title.toLowerCase().includes(needle)) return 20;
|
|
883
|
+
if (entry.category.toLowerCase() === needle) return 15;
|
|
884
|
+
if (entry.description.toLowerCase().includes(needle)) return 10;
|
|
885
|
+
return 0;
|
|
886
|
+
}
|
|
887
|
+
function createServer(options) {
|
|
888
|
+
const registry = new RegistryClient(options.registryUrl);
|
|
889
|
+
const server = new McpServer({
|
|
890
|
+
name: "dowel-ui",
|
|
891
|
+
version: options.version
|
|
892
|
+
}, { instructions: `${options.libraryName} is a source-first React component system: components are installed into the project as editable files, not imported from a dependency.\n\nBefore writing any React UI, call search_components to check whether a component already exists — hand-writing a second Button is the most common mistake here. Call get_guide("conventions") once per session for the styling and accessibility rules, which differ from other libraries in ways worth knowing.` });
|
|
893
|
+
server.registerTool("search_components", {
|
|
894
|
+
title: "Search components",
|
|
895
|
+
description: "Search the component and block catalogue by name, description or category. Call this before building any UI, to find what already exists. Omit the query to list everything.",
|
|
896
|
+
inputSchema: {
|
|
897
|
+
query: z.string().optional().describe("What you need, e.g. \"date\", \"chat\", \"table\", \"agent approval\""),
|
|
898
|
+
category: z.string().optional().describe("Restrict to one category: ai, form, overlay, data, feedback, navigation, display, layout, foundation"),
|
|
899
|
+
kind: z.enum([
|
|
900
|
+
"component",
|
|
901
|
+
"block",
|
|
902
|
+
"any"
|
|
903
|
+
]).optional().describe("Blocks are whole sections assembled from components. Default: any")
|
|
904
|
+
}
|
|
905
|
+
}, async ({ query, category, kind }) => {
|
|
906
|
+
const index = await registry.index();
|
|
907
|
+
const wanted = kind === "component" ? ["registry:ui"] : kind === "block" ? ["registry:block"] : ["registry:ui", "registry:block"];
|
|
908
|
+
const matches = index.items.filter((entry) => wanted.includes(entry.type)).filter((entry) => !category || entry.category === category).map((entry) => ({
|
|
909
|
+
entry,
|
|
910
|
+
rank: score(entry, query ?? "")
|
|
911
|
+
})).filter(({ rank }) => rank > 0).sort((a, b) => b.rank - a.rank || a.entry.name.localeCompare(b.entry.name));
|
|
912
|
+
if (matches.length === 0) return text(`Nothing matches "${query ?? ""}". This registry has ${String(index.items.length)} items — call search_components with no query to see them all. If nothing fits, build it from primitives rather than assuming a component exists.`);
|
|
913
|
+
return text(`${String(matches.length)} match(es) in ${index.generatedFrom}:\n\n` + matches.map(({ entry }) => summarise(entry)).join("\n\n") + `\n\nCall get_component for usage and source. Install with \`npx ${options.cliPackage} add <name>\`.`);
|
|
914
|
+
});
|
|
915
|
+
server.registerTool("get_component", {
|
|
916
|
+
title: "Get a component",
|
|
917
|
+
description: "Everything about one component or block: description, accessibility notes, what it installs alongside, and optionally its full source. Use before writing code that consumes it, so the props and markup come from the registry rather than memory.",
|
|
918
|
+
inputSchema: {
|
|
919
|
+
name: z.string().describe("Registry name, e.g. \"button\", \"ai-prompt-input\", \"dashboard\""),
|
|
920
|
+
include_source: z.boolean().optional().describe("Include the component's full source. Large — ask for it only when editing or extending the component. Default: false")
|
|
921
|
+
}
|
|
922
|
+
}, async ({ name, include_source }) => {
|
|
923
|
+
const index = await registry.index();
|
|
924
|
+
const entry = index.items.find((item) => item.name === name);
|
|
925
|
+
if (!entry) {
|
|
926
|
+
const substring = index.items.map((item) => ({
|
|
927
|
+
item,
|
|
928
|
+
rank: score(item, name)
|
|
929
|
+
})).filter(({ rank }) => rank > 0).sort((a, b) => b.rank - a.rank).slice(0, 5).map(({ item }) => item.name);
|
|
930
|
+
const near = substring.length > 0 ? substring : nearest(index.items.map((item) => item.name), name);
|
|
931
|
+
return text(`No component named "${name}".` + (near.length > 0 ? ` Did you mean: ${near.join(", ")}?` : "") + ` Call search_components to see what exists — do not assume it does.`);
|
|
932
|
+
}
|
|
933
|
+
if (entry.access === "pro") return text([
|
|
934
|
+
`# ${entry.title} \`${entry.name}\``,
|
|
935
|
+
"",
|
|
936
|
+
entry.description,
|
|
937
|
+
"",
|
|
938
|
+
`Type: ${entry.type === "registry:block" ? "block" : "component"} · Category: ${entry.category} · Status: ${entry.status} · **Pro**`,
|
|
939
|
+
"",
|
|
940
|
+
`Install: \`npx ${options.cliPackage} add ${entry.name}\` — requires a licence. Sign in once with \`npx ${options.cliPackage} login\`, or set DOWEL_TOKEN in CI.`,
|
|
941
|
+
"",
|
|
942
|
+
entry.registryDependencies.length > 0 ? `Also installs: ${entry.registryDependencies.join(", ")}\n` : "",
|
|
943
|
+
`${String(entry.fileCount)} file(s). The source is served only to a licence holder, so this server cannot read it; once installed, read it from the project like any other file.`
|
|
944
|
+
].join("\n"));
|
|
945
|
+
const item = await registry.item(name);
|
|
946
|
+
const lines = [
|
|
947
|
+
`# ${item.title} \`${item.name}\``,
|
|
948
|
+
"",
|
|
949
|
+
item.description,
|
|
950
|
+
"",
|
|
951
|
+
`Type: ${item.type === "registry:block" ? "block" : "component"} · Category: ${item.category} · Status: ${item.status}`,
|
|
952
|
+
"",
|
|
953
|
+
`Install: \`npx ${options.cliPackage} add ${item.name}\``,
|
|
954
|
+
`Import: \`import { ... } from "${options.importFrom}"\``,
|
|
955
|
+
""
|
|
956
|
+
];
|
|
957
|
+
if (item.registryDependencies.length > 0) lines.push(`Also installs: ${item.registryDependencies.join(", ")}`, "");
|
|
958
|
+
if (item.dependencies.length > 0) lines.push(`npm packages: ${item.dependencies.join(", ")}`, "");
|
|
959
|
+
if (item.a11y) lines.push("## Accessibility", "", item.a11y, "");
|
|
960
|
+
if (include_source === true) {
|
|
961
|
+
lines.push("## Source", "");
|
|
962
|
+
for (const file of item.files) lines.push(`### \`${file.path}\``, "", "```tsx", file.content, "```", "");
|
|
963
|
+
} else lines.push(`${String(item.files.length)} file(s): ${item.files.map((file) => file.path).join(", ")}.`, "Call again with include_source: true to read them.", "");
|
|
964
|
+
return text(lines.join("\n"));
|
|
965
|
+
});
|
|
966
|
+
server.registerTool("get_guide", {
|
|
967
|
+
title: "Get a guide",
|
|
968
|
+
description: "The rules for writing code with this system: conventions and accessibility, theming and tokens, the AI components, or the full catalogue. Read conventions once per session before writing UI.",
|
|
969
|
+
inputSchema: { topic: z.enum([
|
|
970
|
+
"conventions",
|
|
971
|
+
"theming",
|
|
972
|
+
"ai",
|
|
973
|
+
"catalogue"
|
|
974
|
+
]).describe("conventions: styling and accessibility rules that differ from other libraries. theming: tokens, presets, radius and motion scales. ai: the AI surfaces and when to use each. catalogue: every component and block.") }
|
|
975
|
+
}, async ({ topic }) => {
|
|
976
|
+
const context = docsContext(options, await registry.index());
|
|
977
|
+
const render = {
|
|
978
|
+
conventions: conventionsDoc,
|
|
979
|
+
theming: themesDoc,
|
|
980
|
+
ai: aiDoc,
|
|
981
|
+
catalogue: componentsDoc
|
|
982
|
+
}[topic];
|
|
983
|
+
return text(render(context));
|
|
984
|
+
});
|
|
985
|
+
server.registerTool("install_command", {
|
|
986
|
+
title: "Get the install command",
|
|
987
|
+
description: "The exact command to install components, and the full list of what it will write. Use this instead of composing an npm/pnpm install — these components are source, not a package, and the CLI resolves their dependency graph.",
|
|
988
|
+
inputSchema: { names: z.array(z.string()).min(1).describe("Registry names to install") }
|
|
989
|
+
}, async ({ names }) => {
|
|
990
|
+
let resolved;
|
|
991
|
+
try {
|
|
992
|
+
resolved = await registry.resolve(names);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
return text(`${error instanceof Error ? error.message : String(error)}\n\nCall search_components to check the name.`);
|
|
995
|
+
}
|
|
996
|
+
const extra = resolved.filter((item) => !names.includes(item.name));
|
|
997
|
+
const npm = [...new Set(resolved.flatMap((item) => item.dependencies))];
|
|
998
|
+
return text([
|
|
999
|
+
`\`\`\`bash`,
|
|
1000
|
+
`npx ${options.cliPackage} add ${names.join(" ")}`,
|
|
1001
|
+
`\`\`\``,
|
|
1002
|
+
"",
|
|
1003
|
+
`Writes ${String(resolved.length)} registry item(s): ${resolved.map((item) => item.name).join(", ")}.`,
|
|
1004
|
+
extra.length > 0 ? `${String(extra.length)} of those are dependencies pulled in automatically: ${extra.map((item) => item.name).join(", ")}.` : "Nothing extra is pulled in.",
|
|
1005
|
+
npm.length > 0 ? `npm packages installed alongside: ${npm.join(", ")}.` : "",
|
|
1006
|
+
"",
|
|
1007
|
+
"Safe to re-run. A file the user has edited is never overwritten without `--overwrite`."
|
|
1008
|
+
].filter(Boolean).join("\n"));
|
|
1009
|
+
});
|
|
1010
|
+
server.registerTool("plan_ui", {
|
|
1011
|
+
title: "Plan a screen",
|
|
1012
|
+
description: "Describe a screen and get the registry items that build it, the exact install command, and a starting file. Use this before writing UI from a description — it resolves against the catalogue, so it cannot suggest a component that does not exist.",
|
|
1013
|
+
inputSchema: {
|
|
1014
|
+
prompt: z.string().min(3).describe("What to build, e.g. \"a billing page with usage and invoices\""),
|
|
1015
|
+
format: z.enum([
|
|
1016
|
+
"plan",
|
|
1017
|
+
"code",
|
|
1018
|
+
"both"
|
|
1019
|
+
]).optional().describe("plan: what to install and why. code: a starting file. Default: both")
|
|
1020
|
+
}
|
|
1021
|
+
}, async ({ prompt, format }) => {
|
|
1022
|
+
const plan = planUi(prompt, await registry.index());
|
|
1023
|
+
if (plan.empty) return text(`Nothing in the registry matches "${prompt}".\n\nCall search_components with a simpler term before building anything by hand — and if there genuinely is no component for it, build it from primitives rather than assuming one exists under another name.`);
|
|
1024
|
+
const parts = [];
|
|
1025
|
+
if (format !== "code") {
|
|
1026
|
+
parts.push(renderBrief(plan, {
|
|
1027
|
+
cliPackage: options.cliPackage,
|
|
1028
|
+
docsUrl: options.docsUrl,
|
|
1029
|
+
importFrom: options.importFrom
|
|
1030
|
+
}));
|
|
1031
|
+
parts.push("", "Why each was chosen:", ...[...plan.blocks, ...plan.components].map((item) => `- ${item.entry.name}: ${item.because}`));
|
|
1032
|
+
}
|
|
1033
|
+
if (format !== "plan") parts.push("", "A starting file:", "", "```tsx", renderPlan(plan, {
|
|
1034
|
+
importFrom: options.importFrom,
|
|
1035
|
+
docsUrl: options.docsUrl
|
|
1036
|
+
}).trim(), "```");
|
|
1037
|
+
parts.push("", "Call get_component for each of these before writing props. This plan does not include prop shapes, and a plausible invented prop is worse than none.");
|
|
1038
|
+
return text(parts.join("\n"));
|
|
1039
|
+
});
|
|
1040
|
+
return server;
|
|
1041
|
+
}
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/index.ts
|
|
1044
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1045
|
+
/**
|
|
1046
|
+
* Reads a `--flag value` pair from argv.
|
|
1047
|
+
*
|
|
1048
|
+
* Hand-rolled rather than pulled from a parser: this binary is launched by an
|
|
1049
|
+
* MCP client from a JSON config, takes two options, and every dependency here
|
|
1050
|
+
* is a dependency of every agent session that starts the server.
|
|
1051
|
+
*/
|
|
1052
|
+
function flag(name) {
|
|
1053
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
1054
|
+
if (index === -1) return void 0;
|
|
1055
|
+
return process.argv[index + 1];
|
|
1056
|
+
}
|
|
1057
|
+
async function main() {
|
|
1058
|
+
const registryUrl = flag("registry") ?? process.env.DOWEL_REGISTRY ?? branding.registryUrl;
|
|
1059
|
+
const importFrom = flag("import-from") ?? process.env.DOWEL_IMPORT_FROM ?? `${branding.packageScope}/react`;
|
|
1060
|
+
await createServer({
|
|
1061
|
+
registryUrl,
|
|
1062
|
+
docsUrl: branding.registryUrl.replace(/\/r$/, ""),
|
|
1063
|
+
cliPackage: branding.cliPackage,
|
|
1064
|
+
libraryName: branding.libraryName,
|
|
1065
|
+
importFrom,
|
|
1066
|
+
version
|
|
1067
|
+
}).connect(new StdioServerTransport());
|
|
1068
|
+
}
|
|
1069
|
+
main().catch((error) => {
|
|
1070
|
+
console.error(error instanceof Error ? error.stack : String(error));
|
|
1071
|
+
process.exit(1);
|
|
1072
|
+
});
|
|
1073
|
+
//#endregion
|
|
1074
|
+
export {};
|
|
1075
|
+
|
|
1076
|
+
//# sourceMappingURL=index.js.map
|