@vielzeug/codex 1.0.2
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 +142 -0
- package/data/.cache.json +33 -0
- package/data/llms-full.txt +43590 -0
- package/data/llms.txt +117 -0
- package/data/vielzeug-data.json +14554 -0
- package/dist/__tests__/server.test.js +346 -0
- package/dist/__tests__/server.test.js.map +1 -0
- package/dist/__tests__/unit.test.js +502 -0
- package/dist/__tests__/unit.test.js.map +1 -0
- package/dist/_log.js +5 -0
- package/dist/_log.js.map +1 -0
- package/dist/cli.js +94 -0
- package/dist/cli.js.map +1 -0
- package/dist/data.js +91 -0
- package/dist/data.js.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/frontmatter.js +72 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/generator.js +176 -0
- package/dist/generator.js.map +1 -0
- package/dist/http.js +108 -0
- package/dist/http.js.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/llms.js +162 -0
- package/dist/llms.js.map +1 -0
- package/dist/port.js +12 -0
- package/dist/port.js.map +1 -0
- package/dist/resources.js +4 -0
- package/dist/resources.js.map +1 -0
- package/dist/search.js +125 -0
- package/dist/search.js.map +1 -0
- package/dist/server.js +13 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/index.js +62 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/packages.js +196 -0
- package/dist/tools/packages.js.map +1 -0
- package/dist/tools/refine.js +329 -0
- package/dist/tools/refine.js.map +1 -0
- package/dist/tools/schema.js +37 -0
- package/dist/tools/schema.js.map +1 -0
- package/dist/tools/shared.js +27 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/tools.js +1040 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/package.json +47 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { log } from './_log.js';
|
|
3
|
+
import { packageMeta } from './data.js';
|
|
4
|
+
import { ToolArgError } from './errors.js';
|
|
5
|
+
import { normalisePackage, scorePackage } from './search.js';
|
|
6
|
+
import { DOC_PAGES } from './types.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Result helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
function text(value) {
|
|
11
|
+
return { content: [{ text: value, type: 'text' }] };
|
|
12
|
+
}
|
|
13
|
+
function error(message) {
|
|
14
|
+
return { content: [{ text: message, type: 'text' }], isError: true };
|
|
15
|
+
}
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Argument parsing
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const MAX_ARG_LENGTH = 500;
|
|
20
|
+
/** Validates and returns an optional enum arg, falling back to the provided default. Throws ToolArgError on invalid value. */
|
|
21
|
+
function optionalEnum(args, key, values, fallback) {
|
|
22
|
+
const raw = args[key];
|
|
23
|
+
if (raw === undefined || raw === '')
|
|
24
|
+
return fallback;
|
|
25
|
+
if (typeof raw !== 'string' || !values.includes(raw))
|
|
26
|
+
throw new ToolArgError(`${key}: must be one of ${values.join(', ')}.`);
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
/** Validates a string arg: trims, checks presence and max length. Throws ToolArgError on failure. */
|
|
30
|
+
function requireStr(args, key) {
|
|
31
|
+
const value = args[key];
|
|
32
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
33
|
+
throw new ToolArgError(`${key}: required non-empty string.`);
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (trimmed.length > MAX_ARG_LENGTH)
|
|
36
|
+
throw new ToolArgError(`${key}: exceeds ${MAX_ARG_LENGTH} character limit. Shorten the value.`);
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
export function buildToolContext(data) {
|
|
40
|
+
const refinePkg = data.packages.find((p) => p.slug === 'refine');
|
|
41
|
+
const components = refinePkg && refinePkg.components.length > 0 ? refinePkg.components : null;
|
|
42
|
+
return {
|
|
43
|
+
bySlug: new Map(data.packages.map((pkg) => [pkg.slug, pkg])),
|
|
44
|
+
components,
|
|
45
|
+
componentTags: components?.filter((d) => d.tagName).map((d) => d.tagName) ?? null,
|
|
46
|
+
normalisedPackages: data.packages.map(normalisePackage),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function knownSlugs(context) {
|
|
50
|
+
return [...context.bySlug.keys()].join(', ');
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tools — schema and handler collocated
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// --- list-packages ---
|
|
56
|
+
const listPackagesTool = {
|
|
57
|
+
description: 'List all vielzeug packages with metadata (version, description, category, keywords, exports, availableDocPages, exampleIds, hasSource). Returns a JSON array of PackageMeta objects sorted by slug. Use this tool first to discover available packages, then call get-package for a single package, get-docs for docs, get-source for source, or get-example for a REPL example.',
|
|
58
|
+
inputSchema: { properties: {}, type: 'object' },
|
|
59
|
+
name: 'list-packages',
|
|
60
|
+
run(_args, context) {
|
|
61
|
+
return text(JSON.stringify([...context.bySlug.values()].map(packageMeta), null, 2));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
// --- get-package ---
|
|
65
|
+
const getPackageTool = {
|
|
66
|
+
description: 'Get metadata for a single vielzeug package by slug. Returns a PackageMeta object with version, description, category, keywords, exports, availableDocPages, exampleIds, and hasSource. Use list-packages first to discover available slugs.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
properties: {
|
|
69
|
+
packageSlug: { description: 'Package folder name, e.g. "ripple"', minLength: 1, type: 'string' },
|
|
70
|
+
},
|
|
71
|
+
required: ['packageSlug'],
|
|
72
|
+
type: 'object',
|
|
73
|
+
},
|
|
74
|
+
name: 'get-package',
|
|
75
|
+
run(args, context) {
|
|
76
|
+
const slug = requireStr(args, 'packageSlug');
|
|
77
|
+
const pkg = context.bySlug.get(slug);
|
|
78
|
+
if (!pkg)
|
|
79
|
+
return error(`Package "${slug}" not found. Available slugs: ${knownSlugs(context)}`);
|
|
80
|
+
return text(JSON.stringify(packageMeta(pkg), null, 2));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
// --- get-docs ---
|
|
84
|
+
const getDocsTool = {
|
|
85
|
+
description: 'Read a documentation page for a vielzeug package. Returns Markdown text. page defaults to "index" (overview + quick start). Use "api" for full API reference, "usage" for how-to guide, "examples" for recipe index. Check availableDocPages from list-packages before requesting a specific page.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
properties: {
|
|
88
|
+
packageSlug: { description: 'Package folder name, e.g. "ripple"', minLength: 1, type: 'string' },
|
|
89
|
+
page: { description: 'Doc page to read (defaults to "index")', enum: [...DOC_PAGES], type: 'string' },
|
|
90
|
+
},
|
|
91
|
+
required: ['packageSlug'],
|
|
92
|
+
type: 'object',
|
|
93
|
+
},
|
|
94
|
+
name: 'get-docs',
|
|
95
|
+
run(args, context) {
|
|
96
|
+
const slug = requireStr(args, 'packageSlug');
|
|
97
|
+
const pkg = context.bySlug.get(slug);
|
|
98
|
+
if (!pkg)
|
|
99
|
+
return error(`Package "${slug}" not found. Available slugs: ${knownSlugs(context)}`);
|
|
100
|
+
const page = optionalEnum(args, 'page', DOC_PAGES, 'index');
|
|
101
|
+
const content = pkg.docs[page];
|
|
102
|
+
if (!content) {
|
|
103
|
+
return error(`No "${page}" page for "${slug}". Available: ${pkg.availableDocPages.join(', ') || 'none'}.`);
|
|
104
|
+
}
|
|
105
|
+
return text(content);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
// --- get-source ---
|
|
109
|
+
const getSourceTool = {
|
|
110
|
+
description: 'Read the full src/index.ts source of a vielzeug package. Returns TypeScript text with all exported function signatures, types, and JSDoc. Use this when you need exact type signatures or implementation details not covered by docs. Check hasSource from list-packages first — returns isError if no source is bundled.',
|
|
111
|
+
inputSchema: {
|
|
112
|
+
properties: {
|
|
113
|
+
packageSlug: { description: 'Package folder name, e.g. "ripple"', minLength: 1, type: 'string' },
|
|
114
|
+
},
|
|
115
|
+
required: ['packageSlug'],
|
|
116
|
+
type: 'object',
|
|
117
|
+
},
|
|
118
|
+
name: 'get-source',
|
|
119
|
+
run(args, context) {
|
|
120
|
+
const slug = requireStr(args, 'packageSlug');
|
|
121
|
+
const pkg = context.bySlug.get(slug);
|
|
122
|
+
if (!pkg)
|
|
123
|
+
return error(`Package "${slug}" not found. Available slugs: ${knownSlugs(context)}`);
|
|
124
|
+
if (!pkg.apiSource)
|
|
125
|
+
return error(`Package "${slug}" has no src/index.ts source in bundled data.`);
|
|
126
|
+
return text(pkg.apiSource);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
// --- list-examples ---
|
|
130
|
+
const listExamplesTool = {
|
|
131
|
+
description: 'List runnable REPL code examples for a vielzeug package. Returns a JSON array of { id, name } (no code — use get-example for that). These are the same examples users can run interactively at vielzeug.dev/repl. Returns an empty array (not an error) for packages with no REPL examples (e.g. DOM-output packages like refine, prism, ore).',
|
|
132
|
+
inputSchema: {
|
|
133
|
+
properties: {
|
|
134
|
+
packageSlug: { description: 'Package folder name, e.g. "ripple"', minLength: 1, type: 'string' },
|
|
135
|
+
},
|
|
136
|
+
required: ['packageSlug'],
|
|
137
|
+
type: 'object',
|
|
138
|
+
},
|
|
139
|
+
name: 'list-examples',
|
|
140
|
+
run(args, context) {
|
|
141
|
+
const slug = requireStr(args, 'packageSlug');
|
|
142
|
+
const pkg = context.bySlug.get(slug);
|
|
143
|
+
if (!pkg)
|
|
144
|
+
return error(`Package "${slug}" not found. Available slugs: ${knownSlugs(context)}`);
|
|
145
|
+
return text(JSON.stringify(pkg.examples.map(({ id, name }) => ({ id, name })), null, 2));
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
// --- get-example ---
|
|
149
|
+
const getExampleTool = {
|
|
150
|
+
description: 'Read the full runnable source code of a single REPL example for a vielzeug package. Returns TypeScript text. Use list-examples first to discover valid exampleId values for a package.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
properties: {
|
|
153
|
+
exampleId: { description: 'Example id, e.g. "function-debounce"', minLength: 1, type: 'string' },
|
|
154
|
+
packageSlug: { description: 'Package folder name, e.g. "ripple"', minLength: 1, type: 'string' },
|
|
155
|
+
},
|
|
156
|
+
required: ['packageSlug', 'exampleId'],
|
|
157
|
+
type: 'object',
|
|
158
|
+
},
|
|
159
|
+
name: 'get-example',
|
|
160
|
+
run(args, context) {
|
|
161
|
+
const slug = requireStr(args, 'packageSlug');
|
|
162
|
+
const pkg = context.bySlug.get(slug);
|
|
163
|
+
if (!pkg)
|
|
164
|
+
return error(`Package "${slug}" not found. Available slugs: ${knownSlugs(context)}`);
|
|
165
|
+
const exampleId = requireStr(args, 'exampleId');
|
|
166
|
+
const example = pkg.examples.find((e) => e.id === exampleId);
|
|
167
|
+
if (!example) {
|
|
168
|
+
const available = pkg.examples.map((e) => e.id).join(', ') || 'none';
|
|
169
|
+
return error(`No example "${exampleId}" for "${slug}". Available: ${available}.`);
|
|
170
|
+
}
|
|
171
|
+
return text(example.code);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
// --- search-packages ---
|
|
175
|
+
const searchPackagesTool = {
|
|
176
|
+
description: 'Search vielzeug packages by keyword across name, description, category, keywords, exports, related, docs, REPL examples, and source. Supports multi-word queries (all words must match). Returns a JSON array of SearchHit objects sorted by score descending. score: name(3.9) > category(3.5) > description(3.1) > keywords(2.5) > exports(2.2) > related(2.0) > docs(1.0) > examples(0.95) > source(0.9). Returns empty array (not an error) when nothing matches. Prefer this over list-packages when you know what you are looking for.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
properties: { query: { description: 'Non-empty search term', minLength: 1, type: 'string' } },
|
|
179
|
+
required: ['query'],
|
|
180
|
+
type: 'object',
|
|
181
|
+
},
|
|
182
|
+
name: 'search-packages',
|
|
183
|
+
run(args, context) {
|
|
184
|
+
const query = requireStr(args, 'query');
|
|
185
|
+
const results = context.normalisedPackages
|
|
186
|
+
.map((pkg) => scorePackage(pkg, query))
|
|
187
|
+
.filter((hit) => hit !== null)
|
|
188
|
+
.sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
|
|
189
|
+
return text(JSON.stringify(results, null, 2));
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
// --- list-components ---
|
|
193
|
+
const REFINE_UNAVAILABLE = 'Refine component metadata is unavailable in this snapshot. If using the monorepo, build /refine first then run prepare:data. Published releases include this data automatically.';
|
|
194
|
+
const listComponentsTool = {
|
|
195
|
+
description: 'List all @vielzeug/refine web component tags from bundled Custom Elements Manifest (CEM) metadata. Returns a JSON array with tagName, description, and attrs (name, type, default). Use this to discover available components before calling get-component for full details. Returns isError if refine was not built before data generation.',
|
|
196
|
+
inputSchema: { properties: {}, type: 'object' },
|
|
197
|
+
name: 'list-components',
|
|
198
|
+
run(_args, context) {
|
|
199
|
+
if (!context.components || !context.componentTags)
|
|
200
|
+
return error(REFINE_UNAVAILABLE);
|
|
201
|
+
const tags = context.components
|
|
202
|
+
.filter((d) => d.tagName)
|
|
203
|
+
.map((d) => ({
|
|
204
|
+
attrs: (d.attributes ?? []).map((a) => ({
|
|
205
|
+
name: a.name,
|
|
206
|
+
type: a.type?.text ?? 'string',
|
|
207
|
+
...(a.default !== undefined && { default: a.default }),
|
|
208
|
+
})),
|
|
209
|
+
description: d.description ?? '',
|
|
210
|
+
tagName: d.tagName,
|
|
211
|
+
}));
|
|
212
|
+
return text(JSON.stringify(tags, null, 2));
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
// --- get-component ---
|
|
216
|
+
const getComponentTool = {
|
|
217
|
+
description: 'Get the full Custom Elements Manifest (CEM) declaration for a single @vielzeug/refine component by its HTML tag name (e.g. "ore-button"). Returns a JSON object with attributes, events, slots, CSS parts, CSS properties, and member methods. Call list-components first to get valid tag names.',
|
|
218
|
+
inputSchema: {
|
|
219
|
+
properties: {
|
|
220
|
+
tagName: { description: 'HTML custom element tag, e.g. "ore-button"', minLength: 1, type: 'string' },
|
|
221
|
+
},
|
|
222
|
+
required: ['tagName'],
|
|
223
|
+
type: 'object',
|
|
224
|
+
},
|
|
225
|
+
name: 'get-component',
|
|
226
|
+
run(args, context) {
|
|
227
|
+
const tagName = requireStr(args, 'tagName');
|
|
228
|
+
if (!context.components || !context.componentTags)
|
|
229
|
+
return error(REFINE_UNAVAILABLE);
|
|
230
|
+
const declaration = context.components.find((d) => d.tagName === tagName);
|
|
231
|
+
if (!declaration) {
|
|
232
|
+
return error(`Component "${tagName}" not found. Available tags: ${context.componentTags.join(', ')}`);
|
|
233
|
+
}
|
|
234
|
+
return text(JSON.stringify(declaration, null, 2));
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// generate-template helpers
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
function attributeSnippet(attr) {
|
|
241
|
+
const typeText = attr.type?.text ?? '';
|
|
242
|
+
if (typeText === 'boolean')
|
|
243
|
+
return attr.name;
|
|
244
|
+
// Literal union e.g. 'primary' | 'secondary' — use the first literal
|
|
245
|
+
const firstLiteral = /^['"](\S+?)['"]/.exec(typeText);
|
|
246
|
+
if (firstLiteral)
|
|
247
|
+
return `${attr.name}="${firstLiteral[1]}"`;
|
|
248
|
+
if (attr.default !== undefined && attr.default !== 'undefined' && attr.default !== '')
|
|
249
|
+
return `${attr.name}="${attr.default}"`;
|
|
250
|
+
return `${attr.name}=""`;
|
|
251
|
+
}
|
|
252
|
+
function buildTemplate(decl, scenario) {
|
|
253
|
+
const tag = decl.tagName ?? '';
|
|
254
|
+
const attrs = decl.attributes ?? [];
|
|
255
|
+
const slots = decl.slots ?? [];
|
|
256
|
+
const events = decl.events ?? [];
|
|
257
|
+
const primary = attrs.filter((a) => a.default === undefined);
|
|
258
|
+
const optional = attrs.filter((a) => a.default !== undefined);
|
|
259
|
+
let attrStr = '';
|
|
260
|
+
for (const a of primary)
|
|
261
|
+
attrStr += `\n ${attributeSnippet(a)}`;
|
|
262
|
+
const namedSlots = slots.filter((s) => s.name && s.name !== '');
|
|
263
|
+
const hasDefaultSlot = slots.length === 0 || slots.some((s) => !s.name || s.name === '');
|
|
264
|
+
let inner = '';
|
|
265
|
+
for (const s of namedSlots)
|
|
266
|
+
inner += `\n <span slot="${s.name}">${s.description ?? s.name}</span>`;
|
|
267
|
+
if (hasDefaultSlot)
|
|
268
|
+
inner += '\n Content goes here';
|
|
269
|
+
let comments = '';
|
|
270
|
+
if (optional.length > 0) {
|
|
271
|
+
comments += '\n <!-- Optional attributes:';
|
|
272
|
+
for (const a of optional)
|
|
273
|
+
comments += `\n ${attributeSnippet(a)} (default: ${a.default ?? 'unset'})`;
|
|
274
|
+
comments += '\n -->';
|
|
275
|
+
}
|
|
276
|
+
if (events.length > 0) {
|
|
277
|
+
comments += `\n <!-- Events: ${events.map((e) => e.name).join(', ')} -->`;
|
|
278
|
+
}
|
|
279
|
+
const header = scenario ? `<!-- ${scenario} -->\n` : '';
|
|
280
|
+
return `${header}<${tag}${attrStr}>${comments}${inner ? inner + '\n' : ''}</${tag}>`;
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// validate-component-usage helpers
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
const SAFE_HTML_ATTRS = new Set([
|
|
286
|
+
'class',
|
|
287
|
+
'contenteditable',
|
|
288
|
+
'dir',
|
|
289
|
+
'draggable',
|
|
290
|
+
'exportparts',
|
|
291
|
+
'hidden',
|
|
292
|
+
'id',
|
|
293
|
+
'lang',
|
|
294
|
+
'part',
|
|
295
|
+
'slot',
|
|
296
|
+
'style',
|
|
297
|
+
'tabindex',
|
|
298
|
+
'title',
|
|
299
|
+
]);
|
|
300
|
+
const SAFE_ATTR_PREFIXES = ['aria-', 'data-', 'on'];
|
|
301
|
+
function parseTagAttributes(html, tagName) {
|
|
302
|
+
const pattern = new RegExp(`<${tagName}((?:\\s[^>]*)?)(?:>|/>)`, 'i');
|
|
303
|
+
const match = pattern.exec(html);
|
|
304
|
+
if (!match)
|
|
305
|
+
return null;
|
|
306
|
+
const attrStr = match[1] ?? '';
|
|
307
|
+
const result = new Map();
|
|
308
|
+
const attrRe = /([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s/>]*)))?/g;
|
|
309
|
+
let m;
|
|
310
|
+
while ((m = attrRe.exec(attrStr)) !== null) {
|
|
311
|
+
const [, name, dq, sq, bare] = m;
|
|
312
|
+
if (name)
|
|
313
|
+
result.set(name.toLowerCase(), dq ?? sq ?? bare ?? '');
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
function parseSlotNames(html) {
|
|
318
|
+
const slots = [];
|
|
319
|
+
const re = /\bslot=["']([^"']+)["']/gi;
|
|
320
|
+
let m;
|
|
321
|
+
while ((m = re.exec(html)) !== null) {
|
|
322
|
+
if (m[1])
|
|
323
|
+
slots.push(m[1]);
|
|
324
|
+
}
|
|
325
|
+
return slots;
|
|
326
|
+
}
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Tools — generative UI tier
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// --- generate-template ---
|
|
331
|
+
const generateTemplateTool = {
|
|
332
|
+
description: 'Generate a ready-to-use HTML template for a @vielzeug/refine component. Returns a snippet with required attributes filled with type-appropriate placeholders, optional attributes in a comment block, and all named slots scaffolded. Use this as the starting point for AI-generated declarative UI — avoids hallucinated attribute names. Call list-components first to get valid tag names.',
|
|
333
|
+
inputSchema: {
|
|
334
|
+
properties: {
|
|
335
|
+
scenario: {
|
|
336
|
+
description: 'Optional usage context to include as a leading comment, e.g. "primary call-to-action button"',
|
|
337
|
+
type: 'string',
|
|
338
|
+
},
|
|
339
|
+
tagName: { description: 'HTML custom element tag, e.g. "ore-button"', minLength: 1, type: 'string' },
|
|
340
|
+
},
|
|
341
|
+
required: ['tagName'],
|
|
342
|
+
type: 'object',
|
|
343
|
+
},
|
|
344
|
+
name: 'generate-template',
|
|
345
|
+
run(args, context) {
|
|
346
|
+
const tagName = requireStr(args, 'tagName');
|
|
347
|
+
const scenario = typeof args['scenario'] === 'string' ? args['scenario'].trim() : undefined;
|
|
348
|
+
if (!context.components || !context.componentTags)
|
|
349
|
+
return error(REFINE_UNAVAILABLE);
|
|
350
|
+
const decl = context.components.find((d) => d.tagName === tagName);
|
|
351
|
+
if (!decl)
|
|
352
|
+
return error(`Component "${tagName}" not found. Available tags: ${context.componentTags.join(', ')}`);
|
|
353
|
+
return text(buildTemplate(decl, scenario));
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
// --- get-tokens ---
|
|
357
|
+
const getTokensTool = {
|
|
358
|
+
description: 'List all CSS custom properties (design tokens) exposed by @vielzeug/refine components. Returns a JSON array of { name, description, default, component } objects sorted by name. Pass an optional filter prefix (e.g. "--refine-color") to narrow results. Use when generating dynamic themes or inline styles in AI-driven UI.',
|
|
359
|
+
inputSchema: {
|
|
360
|
+
properties: {
|
|
361
|
+
filter: {
|
|
362
|
+
description: 'Optional prefix to filter token names, e.g. "--refine-color". Case-insensitive.',
|
|
363
|
+
type: 'string',
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
type: 'object',
|
|
367
|
+
},
|
|
368
|
+
name: 'get-tokens',
|
|
369
|
+
run(args, context) {
|
|
370
|
+
if (!context.components)
|
|
371
|
+
return error(REFINE_UNAVAILABLE);
|
|
372
|
+
const rawFilter = typeof args['filter'] === 'string' ? args['filter'].trim().toLowerCase() : undefined;
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
const tokens = [];
|
|
375
|
+
for (const decl of context.components) {
|
|
376
|
+
const componentId = decl.tagName ?? decl.name ?? 'unknown';
|
|
377
|
+
for (const prop of decl.cssProperties ?? []) {
|
|
378
|
+
if (!prop.name || seen.has(prop.name))
|
|
379
|
+
continue;
|
|
380
|
+
if (rawFilter && !prop.name.toLowerCase().startsWith(rawFilter))
|
|
381
|
+
continue;
|
|
382
|
+
seen.add(prop.name);
|
|
383
|
+
tokens.push({
|
|
384
|
+
component: componentId,
|
|
385
|
+
...(prop.default !== undefined && { default: prop.default }),
|
|
386
|
+
...(prop.description && { description: prop.description }),
|
|
387
|
+
name: prop.name,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
tokens.sort((a, b) => a.name.localeCompare(b.name));
|
|
392
|
+
return text(JSON.stringify(tokens, null, 2));
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
// --- validate-component-usage ---
|
|
396
|
+
const validateComponentUsageTool = {
|
|
397
|
+
description: 'Validate AI-generated HTML against a @vielzeug/refine component spec. Checks for unknown attributes and unrecognised slot names. Returns a JSON array of { type, message } objects — an empty array means the usage is valid. Use this to close the generate → validate → fix loop before rendering.',
|
|
398
|
+
inputSchema: {
|
|
399
|
+
properties: {
|
|
400
|
+
html: {
|
|
401
|
+
description: 'HTML fragment containing the component usage to validate (max 5000 chars)',
|
|
402
|
+
minLength: 1,
|
|
403
|
+
type: 'string',
|
|
404
|
+
},
|
|
405
|
+
tagName: {
|
|
406
|
+
description: 'HTML custom element tag to validate against, e.g. "ore-button"',
|
|
407
|
+
minLength: 1,
|
|
408
|
+
type: 'string',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
required: ['html', 'tagName'],
|
|
412
|
+
type: 'object',
|
|
413
|
+
},
|
|
414
|
+
name: 'validate-component-usage',
|
|
415
|
+
run(args, context) {
|
|
416
|
+
const tagName = requireStr(args, 'tagName');
|
|
417
|
+
const rawHtml = args['html'];
|
|
418
|
+
if (typeof rawHtml !== 'string' || rawHtml.trim().length === 0)
|
|
419
|
+
return error('html: required non-empty string.');
|
|
420
|
+
if (rawHtml.length > 5_000)
|
|
421
|
+
return error('html: exceeds 5000 character limit.');
|
|
422
|
+
const html = rawHtml.trim();
|
|
423
|
+
if (!context.components || !context.componentTags)
|
|
424
|
+
return error(REFINE_UNAVAILABLE);
|
|
425
|
+
const decl = context.components.find((d) => d.tagName === tagName);
|
|
426
|
+
if (!decl)
|
|
427
|
+
return error(`Component "${tagName}" not found. Available tags: ${context.componentTags.join(', ')}`);
|
|
428
|
+
const issues = [];
|
|
429
|
+
const knownAttrs = new Set((decl.attributes ?? []).map((a) => a.name.toLowerCase()));
|
|
430
|
+
const knownSlots = new Set((decl.slots ?? []).map((s) => s.name).filter(Boolean));
|
|
431
|
+
const foundAttrs = parseTagAttributes(html, tagName);
|
|
432
|
+
if (!foundAttrs) {
|
|
433
|
+
issues.push({ message: `Could not find opening <${tagName}> tag in the provided HTML.`, type: 'error' });
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
for (const attr of foundAttrs.keys()) {
|
|
437
|
+
if (SAFE_HTML_ATTRS.has(attr))
|
|
438
|
+
continue;
|
|
439
|
+
if (SAFE_ATTR_PREFIXES.some((p) => attr.startsWith(p)))
|
|
440
|
+
continue;
|
|
441
|
+
if (!knownAttrs.has(attr)) {
|
|
442
|
+
issues.push({
|
|
443
|
+
message: `Unknown attribute "${attr}" on <${tagName}>. Known: ${[...knownAttrs].join(', ') || 'none'}.`,
|
|
444
|
+
type: 'error',
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (knownSlots.size > 0) {
|
|
450
|
+
for (const slot of parseSlotNames(html)) {
|
|
451
|
+
if (!knownSlots.has(slot)) {
|
|
452
|
+
issues.push({
|
|
453
|
+
message: `Unknown slot "${slot}" on <${tagName}>. Known slots: ${[...knownSlots].join(', ')}.`,
|
|
454
|
+
type: 'error',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return text(JSON.stringify(issues, null, 2));
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Tools — sandbox contract tier
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
const SANDBOX_IFRAME_ATTRS = {
|
|
466
|
+
referrerpolicy: 'no-referrer',
|
|
467
|
+
sandbox: 'allow-scripts',
|
|
468
|
+
};
|
|
469
|
+
const SANDBOX_CSP = {
|
|
470
|
+
'connect-src': "'none'",
|
|
471
|
+
'default-src': "'none'",
|
|
472
|
+
'form-action': "'none'",
|
|
473
|
+
'img-src': 'data:',
|
|
474
|
+
'script-src': "'unsafe-inline'",
|
|
475
|
+
'style-src': "'unsafe-inline'",
|
|
476
|
+
};
|
|
477
|
+
function buildSandboxCspString() {
|
|
478
|
+
return [
|
|
479
|
+
"default-src 'none'",
|
|
480
|
+
"script-src 'unsafe-inline'",
|
|
481
|
+
"style-src 'unsafe-inline'",
|
|
482
|
+
'img-src data:',
|
|
483
|
+
"connect-src 'none'",
|
|
484
|
+
"form-action 'none'",
|
|
485
|
+
].join('; ');
|
|
486
|
+
}
|
|
487
|
+
function buildSrcdoc(html, styles) {
|
|
488
|
+
// Defense-in-depth: styles is documented as CSS-only, so a literal "</style>" must not be able to
|
|
489
|
+
// close the tag early and inject markup outside of it (html itself is intentionally embedded raw —
|
|
490
|
+
// this document's whole purpose is to run AI-generated content inside the CSP-sandboxed iframe).
|
|
491
|
+
const styleTag = styles ? `<style>${styles.replaceAll('</style', '<\\/style')}</style>` : '';
|
|
492
|
+
const csp = buildSandboxCspString();
|
|
493
|
+
return `<!doctype html>
|
|
494
|
+
<html>
|
|
495
|
+
<head>
|
|
496
|
+
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
|
497
|
+
<meta charset="utf-8">
|
|
498
|
+
${styleTag}
|
|
499
|
+
</head>
|
|
500
|
+
<body>
|
|
501
|
+
${html}
|
|
502
|
+
<script>
|
|
503
|
+
window.addEventListener('message', function(e) {
|
|
504
|
+
var msg = e.data;
|
|
505
|
+
if (msg && msg.type === 'dispose') { document.body.innerHTML = ''; }
|
|
506
|
+
if (msg && msg.type === 'state-update') {
|
|
507
|
+
document.dispatchEvent(new CustomEvent('sandbox:state-update', { detail: msg }));
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
window.onerror = function(message, _src, _line, _col, err) {
|
|
511
|
+
parent.postMessage({ type: 'error', message: String(message), stack: err ? err.stack : undefined }, '*');
|
|
512
|
+
return true;
|
|
513
|
+
};
|
|
514
|
+
parent.postMessage({ type: 'ready' }, '*');
|
|
515
|
+
</script>
|
|
516
|
+
</body>
|
|
517
|
+
</html>`;
|
|
518
|
+
}
|
|
519
|
+
// --- get-sandbox-context ---
|
|
520
|
+
const getSandboxContextTool = {
|
|
521
|
+
description: 'Return the execution constraints of the @vielzeug/sandbox iframe runtime as a JSON object. Use this before generating code for the sandbox — it tells you which browser APIs are available, which are blocked by CSP, and what the iframe sandbox attribute restricts. Call this once per session to orient the AI agent.',
|
|
522
|
+
inputSchema: { properties: {}, type: 'object' },
|
|
523
|
+
name: 'get-sandbox-context',
|
|
524
|
+
run(_args, _context) {
|
|
525
|
+
return text(JSON.stringify({
|
|
526
|
+
cspPolicy: SANDBOX_CSP,
|
|
527
|
+
iframeAttributes: SANDBOX_IFRAME_ATTRS,
|
|
528
|
+
notAvailable: [
|
|
529
|
+
'fetch (blocked by connect-src)',
|
|
530
|
+
'XMLHttpRequest (blocked by connect-src)',
|
|
531
|
+
'WebSocket (blocked by connect-src)',
|
|
532
|
+
'localStorage (blocked by sandbox)',
|
|
533
|
+
'sessionStorage (blocked by sandbox)',
|
|
534
|
+
'indexedDB (blocked by sandbox)',
|
|
535
|
+
'allow-same-origin is NOT set — no cross-iframe DOM access',
|
|
536
|
+
],
|
|
537
|
+
restrictions: [
|
|
538
|
+
"No network requests — connect-src is 'none'",
|
|
539
|
+
"No form submissions — form-action is 'none'",
|
|
540
|
+
'No top-level navigation — sandbox prevents it',
|
|
541
|
+
"No plugins or objects — default-src is 'none'",
|
|
542
|
+
'No same-origin access — sandbox attribute isolates the iframe',
|
|
543
|
+
],
|
|
544
|
+
stateBridge: {
|
|
545
|
+
direction: 'host → sandbox via postMessage; sandbox → host via parent.postMessage',
|
|
546
|
+
note: 'Call get-state-bridge-spec for the full typed protocol',
|
|
547
|
+
},
|
|
548
|
+
windowGlobals: [
|
|
549
|
+
'window',
|
|
550
|
+
'document',
|
|
551
|
+
'customElements',
|
|
552
|
+
'setTimeout',
|
|
553
|
+
'clearTimeout',
|
|
554
|
+
'setInterval',
|
|
555
|
+
'clearInterval',
|
|
556
|
+
'console',
|
|
557
|
+
'Math',
|
|
558
|
+
'JSON',
|
|
559
|
+
'Object',
|
|
560
|
+
'Array',
|
|
561
|
+
'Promise',
|
|
562
|
+
'MutationObserver',
|
|
563
|
+
'ResizeObserver',
|
|
564
|
+
'IntersectionObserver',
|
|
565
|
+
],
|
|
566
|
+
}, null, 2));
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
// --- get-state-bridge-spec ---
|
|
570
|
+
const STATE_BRIDGE_SPEC = `
|
|
571
|
+
/**
|
|
572
|
+
* @vielzeug/sandbox — postMessage state bridge protocol
|
|
573
|
+
*
|
|
574
|
+
* All messages are plain JSON-serialisable objects.
|
|
575
|
+
* The host sends HostMessage into the iframe; the iframe sends SandboxMessage back.
|
|
576
|
+
*/
|
|
577
|
+
|
|
578
|
+
// Host → sandbox (send via sandboxHandle.send(msg) or iframe.contentWindow.postMessage(msg, '*'))
|
|
579
|
+
type HostMessage =
|
|
580
|
+
| { type: 'render'; html: string } // replace body with new HTML
|
|
581
|
+
| { type: 'state-update'; key: string; value: unknown } // push a named value into the sandbox
|
|
582
|
+
| { type: 'dispose' } // clear body and tear down
|
|
583
|
+
|
|
584
|
+
// Sandbox → host (listen via sandboxHandle.onMessage(handler))
|
|
585
|
+
type SandboxMessage =
|
|
586
|
+
| { type: 'ready' } // fired once after srcdoc loads
|
|
587
|
+
| { type: 'event'; name: string; detail: unknown } // custom event bubbled to host
|
|
588
|
+
| { type: 'error'; message: string; stack?: string } // uncaught window.onerror
|
|
589
|
+
| { type: 'resize'; width: number; height: number } // content size changed
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Sandbox-side: listen for state-update messages from host
|
|
593
|
+
*
|
|
594
|
+
* document.addEventListener('sandbox:state-update', (e) => {
|
|
595
|
+
* const { key, value } = e.detail;
|
|
596
|
+
* // update your UI with the new state
|
|
597
|
+
* });
|
|
598
|
+
*
|
|
599
|
+
* Sandbox-side: dispatch an event to the host
|
|
600
|
+
*
|
|
601
|
+
* parent.postMessage({ type: 'event', name: 'ore-click', detail: { id: '42' } }, '*');
|
|
602
|
+
*/
|
|
603
|
+
`.trim();
|
|
604
|
+
const getStateBridgeSpecTool = {
|
|
605
|
+
description: 'Return the full typed postMessage state bridge protocol for @vielzeug/sandbox as TypeScript source with inline usage comments. Use this to understand how to communicate between the host application and sandboxed AI-generated content — including dispatching events back to the host and receiving state updates.',
|
|
606
|
+
inputSchema: { properties: {}, type: 'object' },
|
|
607
|
+
name: 'get-state-bridge-spec',
|
|
608
|
+
run(_args, _context) {
|
|
609
|
+
return text(STATE_BRIDGE_SPEC);
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
// --- generate-sandbox-document ---
|
|
613
|
+
const MAX_SRCDOC_HTML = 20_000;
|
|
614
|
+
const generateSandboxDocumentTool = {
|
|
615
|
+
description: 'Wrap an HTML fragment in a complete srcdoc-ready document for use with @vielzeug/sandbox. Injects the correct Content-Security-Policy meta tag, optional styles, and the postMessage bridge bootstrap script. Pass the result directly to sandboxHandle.render() or set it as iframe.srcdoc. Max html length: 20 000 chars.',
|
|
616
|
+
inputSchema: {
|
|
617
|
+
properties: {
|
|
618
|
+
html: {
|
|
619
|
+
description: 'HTML body content to embed inside the sandbox document (max 20 000 chars)',
|
|
620
|
+
minLength: 1,
|
|
621
|
+
type: 'string',
|
|
622
|
+
},
|
|
623
|
+
styles: {
|
|
624
|
+
description: 'Optional CSS to inject as a <style> block in the document <head>',
|
|
625
|
+
type: 'string',
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
required: ['html'],
|
|
629
|
+
type: 'object',
|
|
630
|
+
},
|
|
631
|
+
name: 'generate-sandbox-document',
|
|
632
|
+
run(args, _context) {
|
|
633
|
+
const rawHtml = args['html'];
|
|
634
|
+
if (typeof rawHtml !== 'string' || rawHtml.trim().length === 0)
|
|
635
|
+
return error('html: required non-empty string.');
|
|
636
|
+
if (rawHtml.length > MAX_SRCDOC_HTML)
|
|
637
|
+
return error(`html: exceeds ${MAX_SRCDOC_HTML} character limit.`);
|
|
638
|
+
const styles = typeof args['styles'] === 'string' && args['styles'].trim().length > 0 ? args['styles'].trim() : undefined;
|
|
639
|
+
return text(buildSrcdoc(rawHtml.trim(), styles));
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
// Tools — ecosystem breadth tier
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
const ORE_DIRECTIVES = [
|
|
646
|
+
{
|
|
647
|
+
description: 'Reactive class string from an object map. Keys are CSS class names; values are booleans or Readable<boolean>.',
|
|
648
|
+
import: '@vielzeug/ore/directives',
|
|
649
|
+
name: 'classMap',
|
|
650
|
+
signature: 'classMap(record: Record<string, boolean | Readable<boolean>>): DirectiveResult',
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
description: 'Keyed reactive list with DOM diffing. The render function receives Readable<T> (item) and Readable<number> (index). A plain T[] is treated as a one-time static snapshot; wrap in signal() for reactivity. Duplicate keys warn in dev.',
|
|
654
|
+
import: '@vielzeug/ore/directives',
|
|
655
|
+
name: 'each',
|
|
656
|
+
signature: 'each<T>(source: Readable<T[]> | T[], key: (item: T) => string, render: (item: Readable<T>, index: Readable<number>) => HTMLResult, fallback?: () => HTMLResult): DirectiveResult',
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
description: 'One-way binding that skips stale DOM writes during active user input. Use alongside a manual @input handler for controlled inputs to prevent cursor jumps.',
|
|
660
|
+
import: '@vielzeug/ore/directives',
|
|
661
|
+
name: 'live',
|
|
662
|
+
signature: 'live(signal: LiveSignal): DirectiveResult',
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
description: 'Two-way value binding for input, select, and textarea elements. select emits on change; input/textarea emit on input. <select multiple> expects Signal<string[]>.',
|
|
666
|
+
import: '@vielzeug/ore/directives',
|
|
667
|
+
name: 'model',
|
|
668
|
+
signature: 'model(signal: Signal<string> | Signal<string[]>): DirectiveResult',
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
description: 'Render a trusted HTML string without escaping. Call setRawSanitizer() before use to avoid XSS. Never pass user-controlled content without sanitization.',
|
|
672
|
+
import: '@vielzeug/ore/directives',
|
|
673
|
+
name: 'raw',
|
|
674
|
+
signature: 'raw(value: string): DirectiveResult',
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
description: 'Reactive inline style string from an object map. Keys are CSS property names; values are strings or Readable<string>.',
|
|
678
|
+
import: '@vielzeug/ore/directives',
|
|
679
|
+
name: 'styleMap',
|
|
680
|
+
signature: 'styleMap(record: Record<string, string | Readable<string>>): DirectiveResult',
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
description: 'Conditional rendering. Truthy and falsy branches are lazy factory functions. The condition tracks reactively when passed as a Readable or getter function.',
|
|
684
|
+
import: '@vielzeug/ore/directives',
|
|
685
|
+
name: 'when',
|
|
686
|
+
signature: 'when(condition: boolean | Readable<boolean> | (() => boolean), truthy: () => HTMLResult, falsy?: () => HTMLResult): DirectiveResult',
|
|
687
|
+
},
|
|
688
|
+
];
|
|
689
|
+
const SPELL_VALIDATORS = [
|
|
690
|
+
{
|
|
691
|
+
category: 'length',
|
|
692
|
+
description: 'value.length <= max.',
|
|
693
|
+
name: 'hasMaxLength',
|
|
694
|
+
signature: 'hasMaxLength(value: string | unknown[], max: number): boolean',
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
category: 'length',
|
|
698
|
+
description: 'value.length >= min.',
|
|
699
|
+
name: 'hasMinLength',
|
|
700
|
+
signature: 'hasMinLength(value: string | unknown[], min: number): boolean',
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
category: 'format',
|
|
704
|
+
description: 'Base64-encoded string (with padding).',
|
|
705
|
+
name: 'isBase64',
|
|
706
|
+
signature: 'isBase64(v: string): boolean',
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
category: 'format',
|
|
710
|
+
description: 'Base64url-encoded string (RFC 4648 §5, padded or unpadded).',
|
|
711
|
+
name: 'isBase64url',
|
|
712
|
+
signature: 'isBase64url(v: string): boolean',
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
category: 'type',
|
|
716
|
+
description: 'Array.isArray guard.',
|
|
717
|
+
name: 'isArray',
|
|
718
|
+
signature: 'isArray(value: unknown): value is unknown[]',
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
category: 'type',
|
|
722
|
+
description: 'Boolean type guard.',
|
|
723
|
+
name: 'isBoolean',
|
|
724
|
+
signature: 'isBoolean(value: unknown): value is boolean',
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
category: 'type',
|
|
728
|
+
description: 'Date instance guard (rejects NaN dates).',
|
|
729
|
+
name: 'isDate',
|
|
730
|
+
signature: 'isDate(value: unknown): value is Date',
|
|
731
|
+
},
|
|
732
|
+
{ category: 'format', description: 'cuid v1 string.', name: 'isCuid', signature: 'isCuid(v: string): boolean' },
|
|
733
|
+
{ category: 'format', description: 'cuid v2 string.', name: 'isCuid2', signature: 'isCuid2(v: string): boolean' },
|
|
734
|
+
{
|
|
735
|
+
category: 'format',
|
|
736
|
+
description: 'ISO 8601 duration (e.g. P1Y2M3DT4H5M6S).',
|
|
737
|
+
name: 'isDuration',
|
|
738
|
+
signature: 'isDuration(v: string): boolean',
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
category: 'format',
|
|
742
|
+
description: 'Basic email address.',
|
|
743
|
+
name: 'isEmail',
|
|
744
|
+
signature: 'isEmail(v: string): boolean',
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
category: 'format',
|
|
748
|
+
description: 'Single extended-pictographic emoji.',
|
|
749
|
+
name: 'isEmoji',
|
|
750
|
+
signature: 'isEmoji(v: string): boolean',
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
category: 'format',
|
|
754
|
+
description: 'Lowercase hexadecimal string (no 0x prefix).',
|
|
755
|
+
name: 'isHex',
|
|
756
|
+
signature: 'isHex(v: string): boolean',
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
category: 'format',
|
|
760
|
+
description: '#RGB, #RRGGBB, or #RRGGBBAA hex color.',
|
|
761
|
+
name: 'isHexColor',
|
|
762
|
+
signature: 'isHexColor(v: string): boolean',
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
category: 'number',
|
|
766
|
+
description: 'value >= min && value <= max.',
|
|
767
|
+
name: 'isInRange',
|
|
768
|
+
signature: 'isInRange(value: number, min: number, max: number): boolean',
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
category: 'number',
|
|
772
|
+
description: 'Number.isInteger check.',
|
|
773
|
+
name: 'isInteger',
|
|
774
|
+
signature: 'isInteger(value: number): boolean',
|
|
775
|
+
},
|
|
776
|
+
{ category: 'format', description: 'IPv4 or IPv6 address.', name: 'isIp', signature: 'isIp(v: string): boolean' },
|
|
777
|
+
{
|
|
778
|
+
category: 'format',
|
|
779
|
+
description: 'YYYY-MM-DD date string (validates calendar correctness).',
|
|
780
|
+
name: 'isIsoDate',
|
|
781
|
+
signature: 'isIsoDate(v: string): boolean',
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
category: 'format',
|
|
785
|
+
description: 'ISO 8601 datetime with optional time and timezone.',
|
|
786
|
+
name: 'isIsoDateTime',
|
|
787
|
+
signature: 'isIsoDateTime(v: string): boolean',
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
category: 'format',
|
|
791
|
+
description: 'Three-segment header.payload.signature JWT.',
|
|
792
|
+
name: 'isJwt',
|
|
793
|
+
signature: 'isJwt(v: string): boolean',
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
category: 'number',
|
|
797
|
+
description: 'value % step === 0.',
|
|
798
|
+
name: 'isMultipleOf',
|
|
799
|
+
signature: 'isMultipleOf(value: number, step: number): boolean',
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
category: 'format',
|
|
803
|
+
description: 'NanoID string; default length 21.',
|
|
804
|
+
name: 'isNanoid',
|
|
805
|
+
signature: 'isNanoid(v: string, length?: number): boolean',
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
category: 'number',
|
|
809
|
+
description: 'value < 0.',
|
|
810
|
+
name: 'isNegative',
|
|
811
|
+
signature: 'isNegative(value: number): boolean',
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
category: 'number',
|
|
815
|
+
description: 'value >= 0.',
|
|
816
|
+
name: 'isNonNegative',
|
|
817
|
+
signature: 'isNonNegative(value: number): boolean',
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
category: 'type',
|
|
821
|
+
description: 'Null or undefined guard (value == null).',
|
|
822
|
+
name: 'isNullOrUndefined',
|
|
823
|
+
signature: 'isNullOrUndefined(value: unknown): value is null | undefined',
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
category: 'type',
|
|
827
|
+
description: 'Number type guard (rejects NaN).',
|
|
828
|
+
name: 'isNumber',
|
|
829
|
+
signature: 'isNumber(value: unknown): value is number',
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
category: 'format',
|
|
833
|
+
description: 'Numeric string (integer or decimal, optional exponent).',
|
|
834
|
+
name: 'isNumeric',
|
|
835
|
+
signature: 'isNumeric(v: string): boolean',
|
|
836
|
+
},
|
|
837
|
+
{
|
|
838
|
+
category: 'number',
|
|
839
|
+
description: 'value > 0.',
|
|
840
|
+
name: 'isPositive',
|
|
841
|
+
signature: 'isPositive(value: number): boolean',
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
category: 'format',
|
|
845
|
+
description: 'Semver version string.',
|
|
846
|
+
name: 'isSemver',
|
|
847
|
+
signature: 'isSemver(v: string): boolean',
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
category: 'format',
|
|
851
|
+
description: 'Lowercase hyphen-separated slug (a-z0-9, no leading/trailing hyphens).',
|
|
852
|
+
name: 'isSlug',
|
|
853
|
+
signature: 'isSlug(v: string): boolean',
|
|
854
|
+
},
|
|
855
|
+
{
|
|
856
|
+
category: 'type',
|
|
857
|
+
description: 'String type guard.',
|
|
858
|
+
name: 'isString',
|
|
859
|
+
signature: 'isString(value: unknown): value is string',
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
category: 'format',
|
|
863
|
+
description: 'HH:MM or HH:MM:SS time string.',
|
|
864
|
+
name: 'isTime',
|
|
865
|
+
signature: 'isTime(v: string): boolean',
|
|
866
|
+
},
|
|
867
|
+
{ category: 'format', description: 'ULID string.', name: 'isUlid', signature: 'isUlid(v: string): boolean' },
|
|
868
|
+
{
|
|
869
|
+
category: 'format',
|
|
870
|
+
description: 'URL string; optional protocols restriction (default: http, https).',
|
|
871
|
+
name: 'isUrl',
|
|
872
|
+
signature: 'isUrl(v: string, protocols?: readonly string[]): boolean',
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
category: 'format',
|
|
876
|
+
description: 'UUID v1–v5 (lowercase 8-4-4-4-12 format).',
|
|
877
|
+
name: 'isUuid',
|
|
878
|
+
signature: 'isUuid(v: string): boolean',
|
|
879
|
+
},
|
|
880
|
+
];
|
|
881
|
+
function extractTypeSignature(source, symbol) {
|
|
882
|
+
const lines = source.split('\n');
|
|
883
|
+
const results = [];
|
|
884
|
+
const symbolRe = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
885
|
+
let capturing = false;
|
|
886
|
+
let depth = 0;
|
|
887
|
+
let buf = [];
|
|
888
|
+
for (const line of lines) {
|
|
889
|
+
if (!capturing) {
|
|
890
|
+
if (/\bexport\b/.test(line) && symbolRe.test(line)) {
|
|
891
|
+
capturing = true;
|
|
892
|
+
buf = [line];
|
|
893
|
+
depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
|
|
894
|
+
if (depth <= 0) {
|
|
895
|
+
results.push(buf.join('\n').trim());
|
|
896
|
+
buf = [];
|
|
897
|
+
capturing = false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
buf.push(line);
|
|
903
|
+
depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
|
|
904
|
+
if (depth <= 0) {
|
|
905
|
+
results.push(buf.join('\n').trim());
|
|
906
|
+
buf = [];
|
|
907
|
+
capturing = false;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return results;
|
|
912
|
+
}
|
|
913
|
+
// --- list-directives ---
|
|
914
|
+
const listDirectivesTool = {
|
|
915
|
+
description: 'List all reactive directives exported by @vielzeug/ore/directives with their TypeScript signatures and descriptions. Returns a JSON array sorted by name. Use when building templates with ore — directives are the primary way to express reactivity, conditionals, and list rendering in HTML templates.',
|
|
916
|
+
inputSchema: { properties: {}, type: 'object' },
|
|
917
|
+
name: 'list-directives',
|
|
918
|
+
run(_args, _context) {
|
|
919
|
+
return text(JSON.stringify(ORE_DIRECTIVES, null, 2));
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
// --- list-validators ---
|
|
923
|
+
const listValidatorsTool = {
|
|
924
|
+
description: 'List all standalone validator functions exported by a @vielzeug package. Returns a JSON array of { name, signature, description, category } sorted by name. Currently supports slug "spell". Validators are pure functions — they return boolean (or a type predicate) and can be used directly inside s.string().validate() or any custom assertion.',
|
|
925
|
+
inputSchema: {
|
|
926
|
+
properties: {
|
|
927
|
+
slug: {
|
|
928
|
+
default: 'spell',
|
|
929
|
+
description: 'Package slug. Currently only "spell" is supported.',
|
|
930
|
+
enum: ['spell'],
|
|
931
|
+
type: 'string',
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
type: 'object',
|
|
935
|
+
},
|
|
936
|
+
name: 'list-validators',
|
|
937
|
+
run(args, context) {
|
|
938
|
+
const slug = typeof args['slug'] === 'string' ? args['slug'].trim() : 'spell';
|
|
939
|
+
if (slug !== 'spell')
|
|
940
|
+
return error(`"${slug}" does not expose a validator catalogue. Supported: spell.`);
|
|
941
|
+
if (!context.bySlug.has('spell'))
|
|
942
|
+
return error('Package "spell" not found in bundled data.');
|
|
943
|
+
return text(JSON.stringify(SPELL_VALIDATORS, null, 2));
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
// --- get-type-signature ---
|
|
947
|
+
const getTypeSignatureTool = {
|
|
948
|
+
description: "Extract the TypeScript export declaration(s) for a named symbol from a @vielzeug package's bundled src/index.ts. Returns the raw declaration lines — useful for verifying the exact signature of a function, type alias, interface, or constant without loading the full source. Returns isError when the package has no bundled source or the symbol is not found.",
|
|
949
|
+
inputSchema: {
|
|
950
|
+
properties: {
|
|
951
|
+
slug: { description: 'Package slug, e.g. "arsenal"', minLength: 1, type: 'string' },
|
|
952
|
+
symbol: {
|
|
953
|
+
description: 'Exported name to look up, e.g. "debounce" or "SearchOptions"',
|
|
954
|
+
minLength: 1,
|
|
955
|
+
type: 'string',
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
required: ['slug', 'symbol'],
|
|
959
|
+
type: 'object',
|
|
960
|
+
},
|
|
961
|
+
name: 'get-type-signature',
|
|
962
|
+
run(args, context) {
|
|
963
|
+
const slug = requireStr(args, 'slug');
|
|
964
|
+
const symbol = requireStr(args, 'symbol');
|
|
965
|
+
const pkg = context.bySlug.get(slug);
|
|
966
|
+
if (!pkg)
|
|
967
|
+
return error(`Package "${slug}" not found. Known slugs: ${knownSlugs(context)}.`);
|
|
968
|
+
if (!pkg.apiSource)
|
|
969
|
+
return error(`Package "${slug}" has no bundled source.`);
|
|
970
|
+
const matches = extractTypeSignature(pkg.apiSource, symbol);
|
|
971
|
+
if (matches.length === 0)
|
|
972
|
+
return error(`"${symbol}" not found in ${slug}/src/index.ts.`);
|
|
973
|
+
return text(matches.join('\n\n'));
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// Registration
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
const TOOLS = [
|
|
980
|
+
listPackagesTool,
|
|
981
|
+
getPackageTool,
|
|
982
|
+
getDocsTool,
|
|
983
|
+
getSourceTool,
|
|
984
|
+
listExamplesTool,
|
|
985
|
+
getExampleTool,
|
|
986
|
+
searchPackagesTool,
|
|
987
|
+
listComponentsTool,
|
|
988
|
+
getComponentTool,
|
|
989
|
+
generateTemplateTool,
|
|
990
|
+
getTokensTool,
|
|
991
|
+
validateComponentUsageTool,
|
|
992
|
+
getSandboxContextTool,
|
|
993
|
+
getStateBridgeSpecTool,
|
|
994
|
+
generateSandboxDocumentTool,
|
|
995
|
+
listDirectivesTool,
|
|
996
|
+
listValidatorsTool,
|
|
997
|
+
getTypeSignatureTool,
|
|
998
|
+
];
|
|
999
|
+
const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
|
|
1000
|
+
const DEBUG = process.env['CODEX_DEBUG'] === '1';
|
|
1001
|
+
function debugArgs(args) {
|
|
1002
|
+
const entries = Object.entries(args)
|
|
1003
|
+
.map(([k, v]) => `${k}=${typeof v === 'string' ? JSON.stringify(v.length > 40 ? `${v.slice(0, 40)}…` : v) : String(v)}`)
|
|
1004
|
+
.join(', ');
|
|
1005
|
+
return entries ? `(${entries})` : '()';
|
|
1006
|
+
}
|
|
1007
|
+
export function registerTools(server, context) {
|
|
1008
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
1009
|
+
tools: TOOLS.map((tool) => ({ description: tool.description, inputSchema: tool.inputSchema, name: tool.name })),
|
|
1010
|
+
}));
|
|
1011
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => {
|
|
1012
|
+
const tool = TOOL_MAP.get(request.params.name);
|
|
1013
|
+
if (!tool) {
|
|
1014
|
+
if (DEBUG)
|
|
1015
|
+
log(`[codex] tool not found: ${request.params.name}`);
|
|
1016
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
|
|
1017
|
+
}
|
|
1018
|
+
const args = request.params.arguments ?? {};
|
|
1019
|
+
if (DEBUG)
|
|
1020
|
+
log(`[codex] → ${tool.name}${debugArgs(args)}`);
|
|
1021
|
+
const t0 = DEBUG ? Date.now() : 0;
|
|
1022
|
+
try {
|
|
1023
|
+
const result = tool.run(args, context);
|
|
1024
|
+
if (DEBUG)
|
|
1025
|
+
log(`[codex] ✓ ${tool.name} (${Date.now() - t0}ms)`);
|
|
1026
|
+
return result;
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
if (err instanceof ToolArgError) {
|
|
1030
|
+
if (DEBUG)
|
|
1031
|
+
log(`[codex] ✗ ${tool.name} arg error: ${err.message}`);
|
|
1032
|
+
return error(err.message);
|
|
1033
|
+
}
|
|
1034
|
+
if (DEBUG)
|
|
1035
|
+
log(`[codex] ✗ ${tool.name} threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
1036
|
+
throw err;
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
//# sourceMappingURL=tools.js.map
|