@texuli/tools 0.0.0-stage → 0.1.0-next.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/README.md +33 -2
- package/dist/a2ui/from-a2ui.d.ts +35 -0
- package/dist/a2ui/from-a2ui.js +180 -0
- package/dist/a2ui/index.d.ts +11 -0
- package/dist/a2ui/index.js +11 -0
- package/dist/a2ui/props.d.ts +10 -0
- package/dist/a2ui/props.js +94 -0
- package/dist/a2ui/schema.d.ts +26 -0
- package/dist/a2ui/schema.js +193 -0
- package/dist/a2ui/to-a2ui.d.ts +30 -0
- package/dist/a2ui/to-a2ui.js +119 -0
- package/dist/a2ui/types.d.ts +71 -0
- package/dist/a2ui/types.js +11 -0
- package/dist/cli-add/add.d.ts +21 -0
- package/dist/cli-add/add.js +241 -0
- package/dist/cli-add/cli.d.ts +25 -0
- package/dist/cli-add/cli.js +157 -0
- package/dist/cli-add/index.d.ts +12 -0
- package/dist/cli-add/index.js +11 -0
- package/dist/cli-add/paths.d.ts +4 -0
- package/dist/cli-add/paths.js +4 -0
- package/dist/cli-add/provenance.d.ts +32 -0
- package/dist/cli-add/provenance.js +78 -0
- package/dist/cli-add/registry.d.ts +19 -0
- package/dist/cli-add/registry.js +50 -0
- package/dist/cli-add/types.d.ts +71 -0
- package/dist/cli-add/types.js +4 -0
- package/dist/cli-init/cli.d.ts +25 -0
- package/dist/cli-init/cli.js +212 -0
- package/dist/cli-init/config.d.ts +10 -0
- package/dist/cli-init/config.js +62 -0
- package/dist/cli-init/detect.d.ts +13 -0
- package/dist/cli-init/detect.js +42 -0
- package/dist/cli-init/files.d.ts +18 -0
- package/dist/cli-init/files.js +59 -0
- package/dist/cli-init/index.d.ts +14 -0
- package/dist/cli-init/index.js +13 -0
- package/dist/cli-init/init.d.ts +14 -0
- package/dist/cli-init/init.js +157 -0
- package/dist/cli-init/safe-path.d.ts +2 -0
- package/dist/cli-init/safe-path.js +26 -0
- package/dist/cli-init/theme-apply.d.ts +2 -0
- package/dist/cli-init/theme-apply.js +69 -0
- package/dist/cli-init/themes.d.ts +37 -0
- package/dist/cli-init/themes.js +83 -0
- package/dist/cli-init/types.d.ts +50 -0
- package/dist/cli-init/types.js +1 -0
- package/dist/cli-init/wire.d.ts +20 -0
- package/dist/cli-init/wire.js +90 -0
- package/dist/cli-update/cli.d.ts +26 -0
- package/dist/cli-update/cli.js +161 -0
- package/dist/cli-update/index.d.ts +11 -0
- package/dist/cli-update/index.js +10 -0
- package/dist/cli-update/lines.d.ts +38 -0
- package/dist/cli-update/lines.js +189 -0
- package/dist/cli-update/provenance.d.ts +14 -0
- package/dist/cli-update/provenance.js +51 -0
- package/dist/cli-update/types.d.ts +83 -0
- package/dist/cli-update/types.js +1 -0
- package/dist/cli-update/update.d.ts +12 -0
- package/dist/cli-update/update.js +261 -0
- package/dist/elements/content.d.ts +15 -0
- package/dist/elements/content.js +53 -0
- package/dist/elements/define.d.ts +49 -0
- package/dist/elements/define.js +281 -0
- package/dist/elements/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- package/dist/golden/axe.d.ts +32 -0
- package/dist/golden/axe.js +22 -0
- package/dist/golden/catalog.d.ts +24 -0
- package/dist/golden/catalog.js +85 -0
- package/dist/golden/cli.d.ts +9 -0
- package/dist/golden/cli.js +45 -0
- package/dist/golden/guidance.d.ts +12 -0
- package/dist/golden/guidance.js +171 -0
- package/dist/golden/index.d.ts +17 -0
- package/dist/golden/index.js +17 -0
- package/dist/golden/outline.d.ts +21 -0
- package/dist/golden/outline.js +68 -0
- package/dist/golden/paint.d.ts +21 -0
- package/dist/golden/paint.js +103 -0
- package/dist/golden/report.d.ts +28 -0
- package/dist/golden/report.js +64 -0
- package/dist/golden/score.d.ts +16 -0
- package/dist/golden/score.js +116 -0
- package/dist/golden/sinks.d.ts +8 -0
- package/dist/golden/sinks.js +11 -0
- package/dist/golden/tasks.d.ts +4 -0
- package/dist/golden/tasks.js +686 -0
- package/dist/golden/types.d.ts +54 -0
- package/dist/golden/types.js +9 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/internal/paths.d.ts +56 -0
- package/dist/internal/paths.js +186 -0
- package/dist/llms/bundle.d.ts +6 -0
- package/dist/llms/bundle.js +47 -0
- package/dist/llms/cli.d.ts +24 -0
- package/dist/llms/cli.js +104 -0
- package/dist/llms/format.d.ts +35 -0
- package/dist/llms/format.js +166 -0
- package/dist/llms/index.d.ts +9 -0
- package/dist/llms/index.js +8 -0
- package/dist/llms/types.d.ts +51 -0
- package/dist/llms/types.js +1 -0
- package/dist/llms/write.d.ts +14 -0
- package/dist/llms/write.js +49 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/server.d.ts +20 -0
- package/dist/mcp/server.js +326 -0
- package/dist/mcp/stdio.d.ts +3 -0
- package/dist/mcp/stdio.js +34 -0
- package/package.json +63 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** Stable path for a component doc: `components/<id>.md`. */
|
|
2
|
+
export function componentDocPath(id) {
|
|
3
|
+
return `components/${id}.md`;
|
|
4
|
+
}
|
|
5
|
+
/** Link href for llms.txt (relative or absolute). */
|
|
6
|
+
export function componentHref(id, baseUrl = '') {
|
|
7
|
+
const rel = componentDocPath(id);
|
|
8
|
+
if (!baseUrl)
|
|
9
|
+
return rel;
|
|
10
|
+
return `${baseUrl.replace(/\/$/, '')}/${rel}`;
|
|
11
|
+
}
|
|
12
|
+
function escapeMd(text) {
|
|
13
|
+
return text.replace(/\|/g, '\\|').replace(/\r\n/g, '\n');
|
|
14
|
+
}
|
|
15
|
+
function listBlock(title, items) {
|
|
16
|
+
if (items.length === 0)
|
|
17
|
+
return '';
|
|
18
|
+
const lines = items.map((item) => `- ${escapeMd(item)}`);
|
|
19
|
+
return `### ${title}\n\n${lines.join('\n')}\n`;
|
|
20
|
+
}
|
|
21
|
+
function propRow(prop) {
|
|
22
|
+
const req = prop.required ? 'yes' : 'no';
|
|
23
|
+
const kind = prop.kind === 'enum' && prop.enumValues?.length
|
|
24
|
+
? `enum(${prop.enumValues.join(' | ')})`
|
|
25
|
+
: prop.kind;
|
|
26
|
+
const trust = prop.trust ? prop.trust : '—';
|
|
27
|
+
const def = prop.default === undefined ? '—' : `\`${JSON.stringify(prop.default)}\``.replace(/\|/g, '\\|');
|
|
28
|
+
const desc = prop.description ? escapeMd(prop.description) : '—';
|
|
29
|
+
return `| \`${prop.name}\` | ${kind} | ${req} | ${trust} | ${def} | ${desc} |`;
|
|
30
|
+
}
|
|
31
|
+
function exampleBlock(example) {
|
|
32
|
+
const props = JSON.stringify(example.props, null, 2);
|
|
33
|
+
const desc = example.description ? `\n\n${escapeMd(example.description)}` : '';
|
|
34
|
+
return `#### ${escapeMd(example.title)} (\`${example.id}\`)${desc}\n\n\`\`\`json\n${props}\n\`\`\`\n`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Full Markdown page for one catalog entry (agent-facing).
|
|
38
|
+
* No HTML; plain Markdown only.
|
|
39
|
+
*/
|
|
40
|
+
export function formatComponentMarkdown(entry, options = {}) {
|
|
41
|
+
const emit = 'emit' in entry && Array.isArray(entry.emit)
|
|
42
|
+
? entry.emit.join(', ')
|
|
43
|
+
: entry.agentEmittable
|
|
44
|
+
? 'developer (agent-emittable)'
|
|
45
|
+
: 'not agent-emittable';
|
|
46
|
+
const group = 'group' in entry && typeof entry.group === 'string' ? entry.group : undefined;
|
|
47
|
+
const a11y = Array.isArray(entry.a11y) ? entry.a11y : entry.a11y ? [entry.a11y] : [];
|
|
48
|
+
const parts = [
|
|
49
|
+
`# ${escapeMd(entry.label)}\n`,
|
|
50
|
+
`> ${escapeMd(entry.summary)}\n`,
|
|
51
|
+
`- **id:** \`${entry.id}\``,
|
|
52
|
+
`- **package:** \`${entry.package}\``,
|
|
53
|
+
`- **tier:** \`${entry.tier}\``,
|
|
54
|
+
`- **agent emittable:** ${entry.agentEmittable === true ? 'yes' : 'no'}`,
|
|
55
|
+
`- **emit:** ${emit}`,
|
|
56
|
+
];
|
|
57
|
+
if (group)
|
|
58
|
+
parts.push(`- **group:** \`${group}\``);
|
|
59
|
+
parts.push('');
|
|
60
|
+
parts.push(listBlock('Use when', entry.useWhen));
|
|
61
|
+
parts.push(listBlock("Don't use when", entry.dontUseWhen));
|
|
62
|
+
parts.push(listBlock('Accessibility', a11y));
|
|
63
|
+
if (entry.props.length > 0) {
|
|
64
|
+
parts.push('### Props\n');
|
|
65
|
+
parts.push('| Name | Kind | Required | Trust | Default | Description |');
|
|
66
|
+
parts.push('| --- | --- | --- | --- | --- | --- |');
|
|
67
|
+
for (const prop of entry.props)
|
|
68
|
+
parts.push(propRow(prop));
|
|
69
|
+
parts.push('');
|
|
70
|
+
}
|
|
71
|
+
if (entry.examples.length > 0) {
|
|
72
|
+
parts.push('### Examples\n');
|
|
73
|
+
for (const example of entry.examples)
|
|
74
|
+
parts.push(exampleBlock(example));
|
|
75
|
+
}
|
|
76
|
+
if (entry.related?.length) {
|
|
77
|
+
parts.push('### Related\n');
|
|
78
|
+
for (const id of entry.related) {
|
|
79
|
+
// A page under `components/` links its siblings directly; `llms-full.txt`
|
|
80
|
+
// sits at the root, beside `components/`.
|
|
81
|
+
const href = options.fromComponents && !options.baseUrl
|
|
82
|
+
? `${id}.md`
|
|
83
|
+
: componentHref(id, options.baseUrl);
|
|
84
|
+
parts.push(`- [\`${id}\`](${href})`);
|
|
85
|
+
}
|
|
86
|
+
parts.push('');
|
|
87
|
+
}
|
|
88
|
+
parts.push('---\n', '_Generated from the Texuli catalog manifest. Do not hand-edit; regenerate with `@texuli/tools/llms`._\n');
|
|
89
|
+
return parts.filter((p, i, arr) => !(p === '' && arr[i - 1] === '')).join('\n');
|
|
90
|
+
}
|
|
91
|
+
/** One llms.txt link line. */
|
|
92
|
+
export function formatLlmsLink(entry, baseUrl = '') {
|
|
93
|
+
const href = componentHref(entry.id, baseUrl);
|
|
94
|
+
return `- [${entry.label}](${href}): ${entry.summary}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Compact `llms.txt` index (llmstxt.org-style).
|
|
98
|
+
* Sections: overview, agent-emittable (Tier U + developer), app chrome.
|
|
99
|
+
*/
|
|
100
|
+
export function formatLlmsTxt(entries, options = {}) {
|
|
101
|
+
const title = options.title ?? 'Texuli';
|
|
102
|
+
const summary = options.summary ??
|
|
103
|
+
'Texuli is a Svelte 5 component system with a closed agent catalog and a sink-free renderer.';
|
|
104
|
+
const baseUrl = options.baseUrl ?? '';
|
|
105
|
+
const split = options.splitTiers !== false;
|
|
106
|
+
const sorted = [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
107
|
+
const emittable = sorted.filter((e) => e.agentEmittable === true);
|
|
108
|
+
const chrome = sorted.filter((e) => e.agentEmittable !== true);
|
|
109
|
+
const lines = [`# ${title}\n`, `> ${summary}\n`];
|
|
110
|
+
if (options.intro?.length) {
|
|
111
|
+
for (const para of options.intro)
|
|
112
|
+
lines.push(`${para}\n`);
|
|
113
|
+
}
|
|
114
|
+
lines.push('This file is generated from catalog manifests. Prefer component pages under `components/` for props and examples.\n');
|
|
115
|
+
if (split) {
|
|
116
|
+
lines.push('## Agent-emittable components\n');
|
|
117
|
+
lines.push('Ids agents may emit into a catalog tree (subject to tier rules). Untrusted model output is limited further by the catalog build tiers.\n');
|
|
118
|
+
if (emittable.length === 0)
|
|
119
|
+
lines.push('_None in this catalog._\n');
|
|
120
|
+
else
|
|
121
|
+
for (const entry of emittable)
|
|
122
|
+
lines.push(formatLlmsLink(entry, baseUrl));
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push('## App and chrome components\n');
|
|
125
|
+
lines.push('Developer-authored UI. Not agent-emittable by default (overlays, forms, display chrome).\n');
|
|
126
|
+
if (chrome.length === 0)
|
|
127
|
+
lines.push('_None in this catalog._\n');
|
|
128
|
+
else
|
|
129
|
+
for (const entry of chrome)
|
|
130
|
+
lines.push(formatLlmsLink(entry, baseUrl));
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lines.push('## Components\n');
|
|
135
|
+
for (const entry of sorted)
|
|
136
|
+
lines.push(formatLlmsLink(entry, baseUrl));
|
|
137
|
+
lines.push('');
|
|
138
|
+
}
|
|
139
|
+
lines.push('## Optional\n');
|
|
140
|
+
lines.push(`- [Full catalog docs](${baseUrl ? `${baseUrl.replace(/\/$/, '')}/` : ''}llms-full.txt)`);
|
|
141
|
+
lines.push('');
|
|
142
|
+
return `${lines
|
|
143
|
+
.join('\n')
|
|
144
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
145
|
+
.trim()}\n`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* `llms-full.txt`: every component Markdown page concatenated with clear separators.
|
|
149
|
+
*/
|
|
150
|
+
export function formatLlmsFullTxt(entries, options = {}) {
|
|
151
|
+
const title = options.title ?? 'Texuli';
|
|
152
|
+
const sorted = [...entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
153
|
+
const parts = [
|
|
154
|
+
`# ${title} — full agent docs\n`,
|
|
155
|
+
`> Complete catalog documentation generated from manifests (${sorted.length} components).\n`,
|
|
156
|
+
'---\n',
|
|
157
|
+
];
|
|
158
|
+
for (const entry of sorted) {
|
|
159
|
+
parts.push(formatComponentMarkdown(entry, { baseUrl: options.baseUrl }));
|
|
160
|
+
parts.push('\n---\n');
|
|
161
|
+
}
|
|
162
|
+
return `${parts
|
|
163
|
+
.join('\n')
|
|
164
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
165
|
+
.trim()}\n`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/tools/llms` — generate `llms.txt`, `llms-full.txt` and per-component
|
|
3
|
+
* Markdown from Texuli catalog manifests (agent docs bundle).
|
|
4
|
+
*/
|
|
5
|
+
export { buildLlmsBundle } from './bundle.js';
|
|
6
|
+
export { isMainModule, type LlmsCliArgs, parseLlmsArgs, runLlmsCli, } from './cli.js';
|
|
7
|
+
export { componentDocPath, componentHref, formatComponentMarkdown, formatLlmsFullTxt, formatLlmsLink, formatLlmsTxt, } from './format.js';
|
|
8
|
+
export type { LlmsBundle, LlmsBundleOptions, LlmsFile, LlmsWriteResult, } from './types.js';
|
|
9
|
+
export { writeLlmsBundle } from './write.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/tools/llms` — generate `llms.txt`, `llms-full.txt` and per-component
|
|
3
|
+
* Markdown from Texuli catalog manifests (agent docs bundle).
|
|
4
|
+
*/
|
|
5
|
+
export { buildLlmsBundle } from "./bundle.js";
|
|
6
|
+
export { isMainModule, parseLlmsArgs, runLlmsCli, } from "./cli.js";
|
|
7
|
+
export { componentDocPath, componentHref, formatComponentMarkdown, formatLlmsFullTxt, formatLlmsLink, formatLlmsTxt, } from "./format.js";
|
|
8
|
+
export { writeLlmsBundle } from "./write.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Catalog, CatalogEntry } from '@texuli/catalog/build';
|
|
2
|
+
import type { CatalogManifest } from '@texuli/catalog/manifest';
|
|
3
|
+
/** One file in the agent docs bundle. */
|
|
4
|
+
export interface LlmsFile {
|
|
5
|
+
/** Path relative to the output root, always POSIX (`llms.txt`, `components/card.md`). */
|
|
6
|
+
path: string;
|
|
7
|
+
/** UTF-8 text body (no BOM). */
|
|
8
|
+
body: string;
|
|
9
|
+
}
|
|
10
|
+
/** Options for building the llms.txt / agent-docs bundle from manifests. */
|
|
11
|
+
export interface LlmsBundleOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Built catalog (preferred) or a plain list of manifests.
|
|
14
|
+
* When manifests are passed, they are treated as developer-tier entries.
|
|
15
|
+
*/
|
|
16
|
+
catalog?: Catalog;
|
|
17
|
+
manifests?: readonly CatalogManifest[];
|
|
18
|
+
/** Site / docs base URL for links (no trailing slash). Default empty (relative). */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
/** Project title in llms.txt. Default `Texuli`. */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** One-line project blurb. */
|
|
23
|
+
summary?: string;
|
|
24
|
+
/** Optional extra intro paragraphs under the title. */
|
|
25
|
+
intro?: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* When true, only `agentEmittable` components appear in the untrusted
|
|
28
|
+
* section of llms.txt. Default true.
|
|
29
|
+
*/
|
|
30
|
+
splitTiers?: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** Full in-memory bundle. */
|
|
33
|
+
export interface LlmsBundle {
|
|
34
|
+
/** Compact index (`llms.txt`). */
|
|
35
|
+
llmsTxt: string;
|
|
36
|
+
/** Full concatenated docs (`llms-full.txt`). */
|
|
37
|
+
llmsFullTxt: string;
|
|
38
|
+
/** Per-component Markdown files. */
|
|
39
|
+
components: readonly LlmsFile[];
|
|
40
|
+
/** Every file including the two roots, sorted by path. */
|
|
41
|
+
files: readonly LlmsFile[];
|
|
42
|
+
/** Entry count used to build the bundle. */
|
|
43
|
+
entryCount: number;
|
|
44
|
+
}
|
|
45
|
+
export type { Catalog, CatalogEntry, CatalogManifest };
|
|
46
|
+
/** Write result when persisting the bundle. */
|
|
47
|
+
export interface LlmsWriteResult {
|
|
48
|
+
outDir: string;
|
|
49
|
+
written: readonly string[];
|
|
50
|
+
dryRun: boolean;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LlmsBundle, LlmsWriteResult } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Write every file from a bundle under `outDir`.
|
|
4
|
+
*
|
|
5
|
+
* Containment (shared with `cli-add` via `../internal/paths.ts`):
|
|
6
|
+
* - `outDir` is resolved once
|
|
7
|
+
* - every bundle path is `path.resolve(outDir, file.path)`
|
|
8
|
+
* - paths that escape `outDir` (`escapesRoot`) are refused
|
|
9
|
+
* - `assertNoSymlinkOnPath` runs before each write so nothing is written
|
|
10
|
+
* through a symlinked file or parent, including overwrite
|
|
11
|
+
*/
|
|
12
|
+
export declare function writeLlmsBundle(outDir: string, bundle: LlmsBundle, options?: {
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
}): Promise<LlmsWriteResult>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { assertNoSymlinkOnPath, escapesRoot } from "../internal/paths.js";
|
|
5
|
+
/**
|
|
6
|
+
* Write every file from a bundle under `outDir`.
|
|
7
|
+
*
|
|
8
|
+
* Containment (shared with `cli-add` via `../internal/paths.ts`):
|
|
9
|
+
* - `outDir` is resolved once
|
|
10
|
+
* - every bundle path is `path.resolve(outDir, file.path)`
|
|
11
|
+
* - paths that escape `outDir` (`escapesRoot`) are refused
|
|
12
|
+
* - `assertNoSymlinkOnPath` runs before each write so nothing is written
|
|
13
|
+
* through a symlinked file or parent, including overwrite
|
|
14
|
+
*/
|
|
15
|
+
export async function writeLlmsBundle(outDir, bundle, options = {}) {
|
|
16
|
+
const dryRun = options.dryRun === true;
|
|
17
|
+
const written = [];
|
|
18
|
+
const outResolved = resolve(outDir);
|
|
19
|
+
for (const file of bundle.files) {
|
|
20
|
+
if (file.path.includes('\0')) {
|
|
21
|
+
throw new Error(`Refusing unsafe bundle path: ${file.path}`);
|
|
22
|
+
}
|
|
23
|
+
if (isAbsolute(file.path) ||
|
|
24
|
+
/^[A-Za-z]:[\\/]/.test(file.path) ||
|
|
25
|
+
file.path.startsWith('\\\\')) {
|
|
26
|
+
throw new Error(`Refusing absolute bundle path: ${file.path}`);
|
|
27
|
+
}
|
|
28
|
+
const abs = resolve(outResolved, file.path);
|
|
29
|
+
if (escapesRoot(outResolved, abs)) {
|
|
30
|
+
throw new Error(`Refusing unsafe bundle path: ${file.path}`);
|
|
31
|
+
}
|
|
32
|
+
written.push(file.path);
|
|
33
|
+
if (dryRun)
|
|
34
|
+
continue;
|
|
35
|
+
// Before creating parents or writing: refuse symlink leaves and parents.
|
|
36
|
+
const before = assertNoSymlinkOnPath(abs, outResolved);
|
|
37
|
+
if (!before.ok) {
|
|
38
|
+
throw new Error(before.error);
|
|
39
|
+
}
|
|
40
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
41
|
+
// Re-check after mkdir (parent may have been replaced / linked).
|
|
42
|
+
const after = assertNoSymlinkOnPath(abs, outResolved);
|
|
43
|
+
if (!after.ok) {
|
|
44
|
+
throw new Error(after.error);
|
|
45
|
+
}
|
|
46
|
+
await writeFile(abs, file.body, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
return { outDir: outResolved, written, dryRun };
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
type JsonRpcId = string | number;
|
|
3
|
+
type JsonRpcError = {
|
|
4
|
+
code: number;
|
|
5
|
+
message: string;
|
|
6
|
+
data?: unknown;
|
|
7
|
+
};
|
|
8
|
+
type JsonRpcResponse = {
|
|
9
|
+
jsonrpc: '2.0';
|
|
10
|
+
id: JsonRpcId | null;
|
|
11
|
+
result: JsonObject;
|
|
12
|
+
} | {
|
|
13
|
+
jsonrpc: '2.0';
|
|
14
|
+
id: JsonRpcId | null;
|
|
15
|
+
error: JsonRpcError;
|
|
16
|
+
};
|
|
17
|
+
/** Create the request handler for a local, read-only MCP server. */
|
|
18
|
+
export declare function createTexuliMcpServer(): (message: unknown) => Promise<JsonRpcResponse | undefined>;
|
|
19
|
+
export declare const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { buildCatalog } from '@texuli/catalog/build';
|
|
2
|
+
import { discoverManifestGroups } from '@texuli/catalog/build/node';
|
|
3
|
+
import { validateCatalogManifest, validateCatalogManifests } from '@texuli/catalog/manifest';
|
|
4
|
+
import { getFullThemeLibraryMeta, listFullThemeLibrary, loadFullLibraryTheme, } from '@texuli/tokens/library-expand';
|
|
5
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
6
|
+
const SERVER_VERSION = '0.0.0';
|
|
7
|
+
const DEFAULT_LIMIT = 50;
|
|
8
|
+
const MAX_LIMIT = 100;
|
|
9
|
+
const SUPPORTED_PROTOCOL_VERSIONS = new Set([
|
|
10
|
+
'2024-11-05',
|
|
11
|
+
'2025-03-26',
|
|
12
|
+
'2025-06-18',
|
|
13
|
+
PROTOCOL_VERSION,
|
|
14
|
+
]);
|
|
15
|
+
function isObject(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function stringArg(args, name, required = false) {
|
|
19
|
+
const value = args[name];
|
|
20
|
+
if (value === undefined && !required)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
23
|
+
throw new TypeError(`"${name}" must be a non-empty string${required ? '' : ' when provided'}.`);
|
|
24
|
+
}
|
|
25
|
+
return value.trim();
|
|
26
|
+
}
|
|
27
|
+
function limitArg(args) {
|
|
28
|
+
const value = args.limit;
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return DEFAULT_LIMIT;
|
|
31
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_LIMIT) {
|
|
32
|
+
throw new TypeError(`"limit" must be an integer from 1 to ${MAX_LIMIT}.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function rejectUnknownArgs(args, allowed) {
|
|
37
|
+
const unknown = Object.keys(args).filter((key) => !allowed.includes(key));
|
|
38
|
+
if (unknown.length > 0) {
|
|
39
|
+
throw new TypeError(`Unknown argument${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function textResult(value, isError = false) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: 'text', text: JSON.stringify(value) }],
|
|
45
|
+
...(isError ? { isError: true } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const COMMON_ANNOTATIONS = {
|
|
49
|
+
readOnlyHint: true,
|
|
50
|
+
destructiveHint: false,
|
|
51
|
+
openWorldHint: false,
|
|
52
|
+
};
|
|
53
|
+
const TOOLS = [
|
|
54
|
+
{
|
|
55
|
+
name: 'catalog',
|
|
56
|
+
description: 'Search the built, content-addressed Texuli component catalog.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
query: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Text to match against component ids, labels and summaries.',
|
|
63
|
+
},
|
|
64
|
+
group: { type: 'string', description: 'Exact manifest group id.' },
|
|
65
|
+
tier: { type: 'string', enum: ['developer', 'untrusted'] },
|
|
66
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_LIMIT },
|
|
67
|
+
},
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
},
|
|
70
|
+
annotations: COMMON_ANNOTATIONS,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'component',
|
|
74
|
+
description: 'Read one component manifest, including props, examples and allowed emit tiers.',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: { id: { type: 'string', minLength: 1 } },
|
|
78
|
+
required: ['id'],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
annotations: COMMON_ANNOTATIONS,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'validate',
|
|
85
|
+
description: 'Validate one catalog manifest or a list of manifests without writing files.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
manifest: { type: 'object', description: 'One candidate manifest.' },
|
|
90
|
+
manifests: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
items: { type: 'object' },
|
|
93
|
+
description: 'A candidate manifest set.',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
},
|
|
98
|
+
annotations: COMMON_ANNOTATIONS,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'example',
|
|
102
|
+
description: 'Find manifest examples by component and/or example id, or search their titles.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
componentId: { type: 'string', minLength: 1 },
|
|
107
|
+
exampleId: { type: 'string', minLength: 1 },
|
|
108
|
+
query: { type: 'string' },
|
|
109
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_LIMIT },
|
|
110
|
+
},
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
},
|
|
113
|
+
annotations: COMMON_ANNOTATIONS,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'theme',
|
|
117
|
+
description: 'Search theme metadata or read the full token definition for one theme id.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
id: { type: 'string', minLength: 1 },
|
|
122
|
+
query: { type: 'string' },
|
|
123
|
+
category: { type: 'string' },
|
|
124
|
+
collection: { type: 'string' },
|
|
125
|
+
mood: { type: 'string' },
|
|
126
|
+
tag: { type: 'string' },
|
|
127
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_LIMIT },
|
|
128
|
+
},
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
},
|
|
131
|
+
annotations: COMMON_ANNOTATIONS,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
function validId(id) {
|
|
135
|
+
return typeof id === 'string' || (typeof id === 'number' && Number.isFinite(id));
|
|
136
|
+
}
|
|
137
|
+
function response(id, result) {
|
|
138
|
+
return { jsonrpc: '2.0', id, result };
|
|
139
|
+
}
|
|
140
|
+
function errorResponse(id, code, message) {
|
|
141
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
142
|
+
}
|
|
143
|
+
/** Create the request handler for a local, read-only MCP server. */
|
|
144
|
+
export function createTexuliMcpServer() {
|
|
145
|
+
let catalogPromise;
|
|
146
|
+
let initialized = false;
|
|
147
|
+
let initializeReceived = false;
|
|
148
|
+
const getCatalog = () => {
|
|
149
|
+
catalogPromise ??= discoverManifestGroups().then(buildCatalog);
|
|
150
|
+
return catalogPromise;
|
|
151
|
+
};
|
|
152
|
+
async function callTool(name, rawArgs) {
|
|
153
|
+
if (!isObject(rawArgs))
|
|
154
|
+
throw new TypeError('Tool arguments must be an object.');
|
|
155
|
+
const args = rawArgs;
|
|
156
|
+
if (name === 'catalog') {
|
|
157
|
+
rejectUnknownArgs(args, ['query', 'group', 'tier', 'limit']);
|
|
158
|
+
const query = stringArg(args, 'query')?.toLowerCase();
|
|
159
|
+
const group = stringArg(args, 'group');
|
|
160
|
+
const tier = stringArg(args, 'tier');
|
|
161
|
+
const limit = limitArg(args);
|
|
162
|
+
if (tier !== undefined && tier !== 'developer' && tier !== 'untrusted') {
|
|
163
|
+
throw new TypeError('"tier" must be "developer" or "untrusted".');
|
|
164
|
+
}
|
|
165
|
+
const built = await getCatalog();
|
|
166
|
+
const filtered = built.catalog.entries.filter((entry) => {
|
|
167
|
+
if (group && entry.group !== group)
|
|
168
|
+
return false;
|
|
169
|
+
if (tier && !entry.emit.includes(tier))
|
|
170
|
+
return false;
|
|
171
|
+
if (query && !`${entry.id} ${entry.label} ${entry.summary}`.toLowerCase().includes(query)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
hash: built.hash,
|
|
178
|
+
fileName: built.fileName,
|
|
179
|
+
total: filtered.length,
|
|
180
|
+
entries: filtered
|
|
181
|
+
.slice(0, limit)
|
|
182
|
+
.map(({ id, package: packageName, label, summary, tier: componentTier, group: groupName, emit, }) => ({
|
|
183
|
+
id,
|
|
184
|
+
package: packageName,
|
|
185
|
+
label,
|
|
186
|
+
summary,
|
|
187
|
+
tier: componentTier,
|
|
188
|
+
group: groupName,
|
|
189
|
+
emit,
|
|
190
|
+
})),
|
|
191
|
+
limit,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
if (name === 'component') {
|
|
195
|
+
rejectUnknownArgs(args, ['id']);
|
|
196
|
+
const id = stringArg(args, 'id', true);
|
|
197
|
+
const entry = (await getCatalog()).catalog.entries.find((item) => item.id === id);
|
|
198
|
+
if (!entry)
|
|
199
|
+
throw new TypeError(`Unknown component "${id}". Search with the catalog tool first.`);
|
|
200
|
+
return { component: entry };
|
|
201
|
+
}
|
|
202
|
+
if (name === 'validate') {
|
|
203
|
+
rejectUnknownArgs(args, ['manifest', 'manifests']);
|
|
204
|
+
const hasManifest = Object.hasOwn(args, 'manifest');
|
|
205
|
+
const hasManifests = Object.hasOwn(args, 'manifests');
|
|
206
|
+
if (hasManifest === hasManifests) {
|
|
207
|
+
throw new TypeError('Provide exactly one of "manifest" or "manifests".');
|
|
208
|
+
}
|
|
209
|
+
if (hasManifest)
|
|
210
|
+
return validateCatalogManifest(args.manifest);
|
|
211
|
+
if (!Array.isArray(args.manifests))
|
|
212
|
+
throw new TypeError('"manifests" must be an array.');
|
|
213
|
+
return validateCatalogManifests(args.manifests);
|
|
214
|
+
}
|
|
215
|
+
if (name === 'example') {
|
|
216
|
+
rejectUnknownArgs(args, ['componentId', 'exampleId', 'query', 'limit']);
|
|
217
|
+
const componentId = stringArg(args, 'componentId');
|
|
218
|
+
const exampleId = stringArg(args, 'exampleId');
|
|
219
|
+
const query = stringArg(args, 'query')?.toLowerCase();
|
|
220
|
+
const limit = limitArg(args);
|
|
221
|
+
const entries = (await getCatalog()).catalog.entries;
|
|
222
|
+
const component = componentId ? entries.find((entry) => entry.id === componentId) : undefined;
|
|
223
|
+
if (componentId && !component) {
|
|
224
|
+
throw new TypeError(`Unknown component "${componentId}". Search with the catalog tool first.`);
|
|
225
|
+
}
|
|
226
|
+
const examples = (component ? [component] : entries).flatMap((entry) => entry.examples
|
|
227
|
+
.filter((example) => !exampleId || example.id === exampleId)
|
|
228
|
+
.filter((example) => !query ||
|
|
229
|
+
`${example.id} ${example.title} ${example.description ?? ''}`
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.includes(query))
|
|
232
|
+
.map((example) => ({ componentId: entry.id, package: entry.package, example })));
|
|
233
|
+
return { total: examples.length, examples: examples.slice(0, limit), limit };
|
|
234
|
+
}
|
|
235
|
+
if (name === 'theme') {
|
|
236
|
+
rejectUnknownArgs(args, ['id', 'query', 'category', 'collection', 'mood', 'tag', 'limit']);
|
|
237
|
+
const id = stringArg(args, 'id');
|
|
238
|
+
const limit = limitArg(args);
|
|
239
|
+
if (id) {
|
|
240
|
+
const themes = await getFullThemeLibraryMeta();
|
|
241
|
+
if (!themes.some((theme) => theme.id === id))
|
|
242
|
+
throw new TypeError(`Unknown theme "${id}".`);
|
|
243
|
+
return { theme: await loadFullLibraryTheme(id) };
|
|
244
|
+
}
|
|
245
|
+
const query = stringArg(args, 'query')?.toLowerCase();
|
|
246
|
+
const collection = stringArg(args, 'collection');
|
|
247
|
+
const mood = stringArg(args, 'mood');
|
|
248
|
+
const matches = (await listFullThemeLibrary({
|
|
249
|
+
category: stringArg(args, 'category'),
|
|
250
|
+
tag: stringArg(args, 'tag'),
|
|
251
|
+
})).filter((theme) => {
|
|
252
|
+
if (collection && theme.collection !== collection)
|
|
253
|
+
return false;
|
|
254
|
+
if (mood && !theme.mood.includes(mood))
|
|
255
|
+
return false;
|
|
256
|
+
if (query &&
|
|
257
|
+
!`${theme.id} ${theme.name} ${theme.description} ${theme.category} ${theme.tags.join(' ')}`
|
|
258
|
+
.toLowerCase()
|
|
259
|
+
.includes(query)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
});
|
|
264
|
+
return { total: matches.length, themes: matches.slice(0, limit), limit };
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
267
|
+
}
|
|
268
|
+
return async (message) => {
|
|
269
|
+
if (!isObject(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
270
|
+
const id = isObject(message) && validId(message.id) ? message.id : null;
|
|
271
|
+
return errorResponse(id, -32600, 'Invalid Request');
|
|
272
|
+
}
|
|
273
|
+
const hasId = Object.hasOwn(message, 'id');
|
|
274
|
+
const id = validId(message.id) ? message.id : null;
|
|
275
|
+
if (hasId && id === null)
|
|
276
|
+
return errorResponse(null, -32600, 'Invalid Request');
|
|
277
|
+
if (!hasId) {
|
|
278
|
+
if (message.method === 'notifications/initialized')
|
|
279
|
+
initialized = initializeReceived;
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
if (message.method === 'initialize') {
|
|
283
|
+
const params = message.params;
|
|
284
|
+
if (!isObject(params) || typeof params.protocolVersion !== 'string') {
|
|
285
|
+
return errorResponse(id, -32602, 'initialize requires params.protocolVersion.');
|
|
286
|
+
}
|
|
287
|
+
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.has(params.protocolVersion)
|
|
288
|
+
? params.protocolVersion
|
|
289
|
+
: PROTOCOL_VERSION;
|
|
290
|
+
initializeReceived = true;
|
|
291
|
+
initialized = false;
|
|
292
|
+
return response(id, {
|
|
293
|
+
protocolVersion,
|
|
294
|
+
capabilities: { tools: { listChanged: false } },
|
|
295
|
+
serverInfo: { name: 'texuli', version: SERVER_VERSION },
|
|
296
|
+
instructions: 'Read-only catalog, component, validation, example and theme tools. No files or network are modified.',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (message.method === 'ping')
|
|
300
|
+
return response(id, {});
|
|
301
|
+
if (!initialized)
|
|
302
|
+
return errorResponse(id, -32002, 'Server not initialized.');
|
|
303
|
+
if (message.method === 'tools/list') {
|
|
304
|
+
return response(id, { tools: TOOLS });
|
|
305
|
+
}
|
|
306
|
+
if (message.method === 'tools/call') {
|
|
307
|
+
const params = message.params;
|
|
308
|
+
if (!isObject(params) || typeof params.name !== 'string') {
|
|
309
|
+
return errorResponse(id, -32602, 'tools/call requires params.name.');
|
|
310
|
+
}
|
|
311
|
+
const toolName = params.name;
|
|
312
|
+
const tool = TOOLS.find((item) => item.name === toolName);
|
|
313
|
+
if (!tool)
|
|
314
|
+
return errorResponse(id, -32602, `Unknown tool: ${toolName}`);
|
|
315
|
+
try {
|
|
316
|
+
const data = await callTool(tool.name, params.arguments ?? {});
|
|
317
|
+
return response(id, textResult(data));
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
return response(id, textResult({ error: error instanceof Error ? error.message : 'Tool failed.' }, true));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return errorResponse(id, -32601, `Method not found: ${message.method}`);
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
export const MCP_PROTOCOL_VERSION = PROTOCOL_VERSION;
|