@webjsdev/mcp 0.1.17 → 0.1.19
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 +30 -103
- package/package.json +5 -10
- package/src/brief.js +52 -0
- package/src/catalog.js +82 -0
- package/src/mcp-init.js +139 -0
- package/src/mcp-source.js +94 -8
- package/src/mcp.js +96 -164
- package/resources/AGENTS.md +0 -701
- package/resources/SKILL.md +0 -286
- package/resources/corpus.json +0 -6
- package/resources/references/auth-and-sessions.md +0 -300
- package/resources/references/built-ins.md +0 -336
- package/resources/references/client-router-and-streaming.md +0 -313
- package/resources/references/components.md +0 -469
- package/resources/references/data-and-actions.md +0 -319
- package/resources/references/module-structure.md +0 -229
- package/resources/references/muscle-memory-gotchas.md +0 -341
- package/resources/references/optimistic-ui.md +0 -179
- package/resources/references/routing-and-pages.md +0 -323
- package/resources/references/runtime.md +0 -108
- package/resources/references/service-worker.md +0 -80
- package/resources/references/styling.md +0 -295
- package/resources/references/testing.md +0 -223
- package/resources/references/typescript.md +0 -231
- package/resources/references/ui-kit.md +0 -127
- package/src/mcp-docs.js +0 -641
package/src/mcp-docs.js
DELETED
|
@@ -1,641 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The knowledge + authoring layer for `webjs mcp` (#376).
|
|
3
|
-
*
|
|
4
|
-
* `webjs mcp` (lib/mcp.js) started as four READ-ONLY introspection tools. This
|
|
5
|
-
* module adds the second layer the Next.js MCP (`next-devtools-mcp`) showed is
|
|
6
|
-
* the high-leverage one: the framework knowledge an agent needs to author
|
|
7
|
-
* idiomatic WebJs code, surfaced as MCP RESOURCES (the WebJs skill references
|
|
8
|
-
* plus the `AGENTS.md` contract), an `init` "read first" primer that fights the
|
|
9
|
-
* React/RSC mental model, a `docs` retrieval tool, and guided-workflow PROMPTS
|
|
10
|
-
* built from the recipes.
|
|
11
|
-
*
|
|
12
|
-
* It stays hand-rolled and ZERO-dependency (no `@modelcontextprotocol/sdk`),
|
|
13
|
-
* consistent with WebJs being buildless + minimal-deps. Everything here is PURE
|
|
14
|
-
* given its injected `{ docsDir, agentsPath, readFile }` deps, so it is testable
|
|
15
|
-
* in-process without booting a server or touching the real filesystem.
|
|
16
|
-
*
|
|
17
|
-
* Docs resolution (so `npx @webjsdev/mcp` is self-contained): the APP's own
|
|
18
|
-
* installed `@webjsdev/mcp` corpus wins, then the running server's snapshot
|
|
19
|
-
* bundled under `<pkg>/resources/references` (copied at `prepack`, see
|
|
20
|
-
* `scripts/copy-mcp-resources.js`), then a repo-root-relative fallback, which is
|
|
21
|
-
* the skill on a monorepo dev run. {@link resolveDocsLocation} encodes that
|
|
22
|
-
* lookup so source stays single
|
|
23
|
-
* (no committed duplicate docs). `init` reports which of the three it served,
|
|
24
|
-
* from the `corpus.json` build stamp, and warns when the app's installed mcp is
|
|
25
|
-
* newer than the running server (#1319).
|
|
26
|
-
*
|
|
27
|
-
* @module mcp-docs
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { existsSync } from 'node:fs';
|
|
31
|
-
import { dirname, join, resolve } from 'node:path';
|
|
32
|
-
import { fileURLToPath } from 'node:url';
|
|
33
|
-
|
|
34
|
-
/** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
|
|
35
|
-
const DOCS_SCHEME = 'webjs-docs://';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The paths of one bundled corpus root (`<something>/resources`), tagged with
|
|
39
|
-
* which rung it is so a caller can say WHOSE docs it ended up serving.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} root the `resources` directory
|
|
42
|
-
* @param {'app' | 'bundled'} corpusSource which rung this root is
|
|
43
|
-
* @returns {{ docsDir: string, agentsPath: string, skillPath: string, corpusPath: string, corpusSource: 'app' | 'bundled' }}
|
|
44
|
-
*/
|
|
45
|
-
function bundleAt(root, corpusSource) {
|
|
46
|
-
return {
|
|
47
|
-
docsDir: join(root, 'references'),
|
|
48
|
-
agentsPath: join(root, 'AGENTS.md'),
|
|
49
|
-
skillPath: join(root, 'SKILL.md'),
|
|
50
|
-
corpusPath: join(root, 'corpus.json'),
|
|
51
|
-
corpusSource,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
|
|
57
|
-
* and build-stamp paths. Three rungs, highest first:
|
|
58
|
-
*
|
|
59
|
-
* 1. `<appDir>/node_modules/@webjsdev/mcp/resources`, the APP's own installed
|
|
60
|
-
* copy. It wins because it is version-matched to the framework the agent is
|
|
61
|
-
* editing, and so is the only corpus that can be correct about that app. A
|
|
62
|
-
* globally installed server otherwise serves a snapshot frozen at whatever
|
|
63
|
-
* it was published from, which taught reverted guidance for months (#1319).
|
|
64
|
-
* 2. `<pkgRoot>/resources`, the running server's own bundled snapshot (copied
|
|
65
|
-
* at `prepack`, see `scripts/copy-mcp-resources.js`).
|
|
66
|
-
* 3. A repo-root-relative path, which is the monorepo skill on a dev/test run.
|
|
67
|
-
* This rung is an unconditional FALLBACK, not a checkout probe, so a
|
|
68
|
-
* published install whose `resources/` bundle is missing lands here too,
|
|
69
|
-
* with `repoRoot` inside `node_modules` and an empty corpus. `corpusPath` is
|
|
70
|
-
* `null` because no bundle was found, which is why nothing downstream may
|
|
71
|
-
* assert a checkout on this rung.
|
|
72
|
-
*
|
|
73
|
-
* The rung-1 probe is a plain `existsSync` on the references directory rather
|
|
74
|
-
* than `require.resolve('@webjsdev/mcp')` from `appDir`, because what this needs
|
|
75
|
-
* is "a directory of markdown at a known path" and nothing more. Resolving the
|
|
76
|
-
* package would go through its `exports` map, and in this monorepo that lands on
|
|
77
|
-
* the workspace symlink back to `packages/mcp`, whose `resources/` does not
|
|
78
|
-
* exist in a checkout, so the resolve would fall to rung 3 anyway.
|
|
79
|
-
*
|
|
80
|
-
* Any returned path may not exist; callers fail soft (an empty corpus is valid,
|
|
81
|
-
* never a crash).
|
|
82
|
-
*
|
|
83
|
-
* @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
|
|
84
|
-
* @param {string} [appDir] the app being asked about; enables the rung-1 probe
|
|
85
|
-
* @returns {{ docsDir: string, agentsPath: string, skillPath: string, corpusPath: string | null, corpusSource: 'app' | 'bundled' | 'repo' }}
|
|
86
|
-
*/
|
|
87
|
-
export function resolveDocsLocation(moduleUrl, appDir) {
|
|
88
|
-
const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
|
|
89
|
-
const pkgRoot = resolve(here, '..'); // packages/mcp/src -> packages/mcp
|
|
90
|
-
const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
|
|
91
|
-
|
|
92
|
-
if (typeof appDir === 'string' && appDir) {
|
|
93
|
-
const appRoot = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'resources');
|
|
94
|
-
if (existsSync(join(appRoot, 'references'))) return bundleAt(appRoot, 'app');
|
|
95
|
-
}
|
|
96
|
-
const bundledRoot = join(pkgRoot, 'resources');
|
|
97
|
-
if (existsSync(join(bundledRoot, 'references'))) return bundleAt(bundledRoot, 'bundled');
|
|
98
|
-
|
|
99
|
-
const skill = join(repoRoot, '.agents', 'skills', 'webjs');
|
|
100
|
-
return {
|
|
101
|
-
docsDir: join(skill, 'references'),
|
|
102
|
-
agentsPath: join(repoRoot, 'AGENTS.md'),
|
|
103
|
-
skillPath: join(skill, 'SKILL.md'),
|
|
104
|
-
corpusPath: null,
|
|
105
|
-
corpusSource: 'repo',
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Compare two `major.minor.patch` versions, returning `-1` / `0` / `1`.
|
|
111
|
-
*
|
|
112
|
-
* Dependency-free by design (this package ships zero dependencies), and
|
|
113
|
-
* deliberately coarse: any prerelease or build suffix is stripped before
|
|
114
|
-
* comparing, a missing segment reads as `0`, and a non-numeric segment reads as
|
|
115
|
-
* `0`. Its only consumer is the staleness warning, where the question is "is the
|
|
116
|
-
* app's copy meaningfully newer", so ranking two prereleases of one patch is not
|
|
117
|
-
* a case that needs an answer.
|
|
118
|
-
*
|
|
119
|
-
* @param {string} a
|
|
120
|
-
* @param {string} b
|
|
121
|
-
* @returns {-1 | 0 | 1}
|
|
122
|
-
*/
|
|
123
|
-
export function compareVersions(a, b) {
|
|
124
|
-
const parts = (v) => String(v == null ? '' : v).trim().split(/[-+]/)[0].split('.');
|
|
125
|
-
const pa = parts(a);
|
|
126
|
-
const pb = parts(b);
|
|
127
|
-
for (let i = 0; i < 3; i++) {
|
|
128
|
-
const x = Number(pa[i]);
|
|
129
|
-
const y = Number(pb[i]);
|
|
130
|
-
const nx = Number.isFinite(x) ? x : 0;
|
|
131
|
-
const ny = Number.isFinite(y) ? y : 0;
|
|
132
|
-
if (nx !== ny) return nx < ny ? -1 : 1;
|
|
133
|
-
}
|
|
134
|
-
return 0;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The `@webjsdev/mcp` version installed in `appDir`, or `null` when that cannot
|
|
139
|
-
* be known (no app dir, no install, an unreadable or unparseable manifest, no
|
|
140
|
-
* `version` field). Only mcp's own version is read: the corpus ships INSIDE this
|
|
141
|
-
* package, so mcp's version is the only one that bounds the docs, and reading
|
|
142
|
-
* the other `@webjsdev/*` manifests would multiply the failure modes without
|
|
143
|
-
* adding signal.
|
|
144
|
-
*
|
|
145
|
-
* @param {string} [appDir]
|
|
146
|
-
* @param {{ exists?: (p: string) => boolean, readFile: (p: string, enc: string) => Promise<string> }} deps
|
|
147
|
-
* @returns {Promise<string | null>}
|
|
148
|
-
*/
|
|
149
|
-
export async function readAppMcpVersion(appDir, deps) {
|
|
150
|
-
if (typeof appDir !== 'string' || !appDir) return null;
|
|
151
|
-
const manifest = join(appDir, 'node_modules', '@webjsdev', 'mcp', 'package.json');
|
|
152
|
-
try {
|
|
153
|
-
if (deps.exists && !deps.exists(manifest)) return null;
|
|
154
|
-
const version = JSON.parse(await deps.readFile(manifest, 'utf8')).version;
|
|
155
|
-
return typeof version === 'string' && version ? version : null;
|
|
156
|
-
} catch {
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* The one line naming WHICH docs corpus `init` is serving, so an agent reading a
|
|
163
|
-
* stale global install can see that it is stale (#1319). Never throws: a
|
|
164
|
-
* missing, unreadable, or malformed stamp degrades to the unstamped wording,
|
|
165
|
-
* because refusing to answer is strictly worse than answering with a caveat.
|
|
166
|
-
*
|
|
167
|
-
* @param {object} deps
|
|
168
|
-
* @returns {Promise<string>}
|
|
169
|
-
*/
|
|
170
|
-
async function corpusLine(deps) {
|
|
171
|
-
// Rung 3 has no stamp, and it is a FALLBACK rather than a checkout probe, so
|
|
172
|
-
// this must not claim a checkout: a published install whose `resources/` is
|
|
173
|
-
// missing lands here too, with an empty corpus and no checkout to name.
|
|
174
|
-
if (!deps.corpusPath) return 'Docs corpus: no bundled snapshot; serving whatever docs this server resolved locally.';
|
|
175
|
-
const unstamped = 'Docs corpus: an unstamped @webjsdev/mcp bundled snapshot.';
|
|
176
|
-
try {
|
|
177
|
-
const stamp = JSON.parse(await deps.readFile(deps.corpusPath, 'utf8'));
|
|
178
|
-
if (!stamp || !stamp.version) return unstamped;
|
|
179
|
-
const pkg = stamp.package || '@webjsdev/mcp';
|
|
180
|
-
const date = typeof stamp.copiedAt === 'string' && stamp.copiedAt ? stamp.copiedAt.slice(0, 10) : 'an unknown date';
|
|
181
|
-
// A SHA the stamp could not capture drops the clause rather than printing a
|
|
182
|
-
// placeholder: the point of a provenance line is that no answer reads as no
|
|
183
|
-
// answer. Same reason the shape is checked, since only a real commit id
|
|
184
|
-
// resolves to a diff.
|
|
185
|
-
const sha = typeof stamp.sha === 'string' && /^[0-9a-f]{40}$/.test(stamp.sha) ? stamp.sha.slice(0, 7) : null;
|
|
186
|
-
return sha
|
|
187
|
-
? `Docs corpus: ${pkg}@${stamp.version}, copied from webjsdev/webjs ${sha} on ${date}.`
|
|
188
|
-
: `Docs corpus: ${pkg}@${stamp.version}, copied on ${date}.`;
|
|
189
|
-
} catch {
|
|
190
|
-
return unstamped;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* What the warning says about WHOSE docs it just served, one clause per rung.
|
|
196
|
-
*
|
|
197
|
-
* A `Map` rather than an object literal, for the reason `docsDepsCache` is one:
|
|
198
|
-
* an object literal inherits `Object.prototype`, so a lookup keyed on an
|
|
199
|
-
* unexpected value like `constructor` or `toString` returns a truthy FUNCTION,
|
|
200
|
-
* the `||` fallback never fires, and the warning splices native-code source into
|
|
201
|
-
* its own text. A `Map` has no prototype keys, so the fallback covers every value
|
|
202
|
-
* that is not one of these three.
|
|
203
|
-
*
|
|
204
|
-
* The `repo` clause deliberately does NOT claim a live checkout. Rung 3 is an
|
|
205
|
-
* unconditional fallback rather than a checkout probe (see
|
|
206
|
-
* {@link resolveDocsLocation}), so it is also where a published install with no
|
|
207
|
-
* bundled `resources/` lands, and there the "checkout" does not exist and the
|
|
208
|
-
* corpus is empty.
|
|
209
|
-
*/
|
|
210
|
-
const SERVED_CLAUSE = new Map([
|
|
211
|
-
['app', "The docs below come from this app's own copy, so they match it; update the server so its TOOLS match too."],
|
|
212
|
-
['bundled', "The docs below are the server's own older snapshot, not this app's copy."],
|
|
213
|
-
['repo', "The docs below are whatever this server resolved locally, not this app's copy."],
|
|
214
|
-
]);
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* The staleness warning, or `null` when there is nothing to warn about.
|
|
218
|
-
*
|
|
219
|
-
* Fires only when the app's installed `@webjsdev/mcp` is strictly NEWER than the
|
|
220
|
-
* running server's. Equal or lower is silent: a newer server reading an older app
|
|
221
|
-
* is the ordinary shape and is not a defect. It warns rather than failing,
|
|
222
|
-
* because a read-only knowledge tool that refuses to answer sends the agent back
|
|
223
|
-
* to its training data, which is the thing the caveat exists to correct.
|
|
224
|
-
*
|
|
225
|
-
* Two things the wording is careful about, because the obvious phrasings are both
|
|
226
|
-
* wrong:
|
|
227
|
-
*
|
|
228
|
-
* - It does NOT name a global install as the thing to update. Nothing here can
|
|
229
|
-
* observe how the server was started, and the shipped configurations are an
|
|
230
|
-
* `npx @webjsdev/mcp` (whose staleness is a package cache) and `webjs mcp`
|
|
231
|
-
* (whose staleness is `@webjsdev/cli`'s own dependency), neither of which a
|
|
232
|
-
* `npm i -g` would fix.
|
|
233
|
-
* - It does NOT promise the docs below are the app's. The warning's probe is the
|
|
234
|
-
* app's `package.json`, while the corpus rung's probe is that install's
|
|
235
|
-
* `resources/references`, and the two can disagree: a workspace-linked or
|
|
236
|
-
* otherwise resources-less install has a manifest to read but no corpus to
|
|
237
|
-
* serve, so the corpus falls through to the server's older snapshot. That case
|
|
238
|
-
* gets an extra clause naming what was actually served, rather than a claim
|
|
239
|
-
* that contradicts the corpus line printed directly beneath it.
|
|
240
|
-
*
|
|
241
|
-
* @param {object} deps
|
|
242
|
-
* @returns {Promise<string | null>}
|
|
243
|
-
*/
|
|
244
|
-
async function staleWarning(deps) {
|
|
245
|
-
const server = deps.serverVersion;
|
|
246
|
-
// '0.0.0' is the sentinel both entry points fall back to when they cannot read
|
|
247
|
-
// their own manifest, so it means "unknown", not "older than everything".
|
|
248
|
-
if (!server || server === '0.0.0') return null;
|
|
249
|
-
const app = await readAppMcpVersion(deps.appDir, deps);
|
|
250
|
-
if (!app || compareVersions(app, server) <= 0) return null;
|
|
251
|
-
const served = SERVED_CLAUSE.get(deps.corpusSource) || "The docs below may not be this app's copy.";
|
|
252
|
-
return (
|
|
253
|
-
`Warning: this MCP server is @webjsdev/mcp@${server}, but this app has @webjsdev/mcp@${app}, ` +
|
|
254
|
-
`so the server may be stale. ${served} ` +
|
|
255
|
-
'Update whichever copy runs this server: a global install (npm i -g @webjsdev/mcp@latest, ' +
|
|
256
|
-
'or bun add -g @webjsdev/mcp), the package cache behind npx @webjsdev/mcp, ' +
|
|
257
|
-
'@webjsdev/cli when the server is started as webjs mcp, or the checkout it runs from.'
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
|
|
263
|
-
* `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
|
|
264
|
-
*
|
|
265
|
-
* @param {string} file
|
|
266
|
-
* @returns {string}
|
|
267
|
-
*/
|
|
268
|
-
function docName(file) {
|
|
269
|
-
return file.replace(/\.md$/i, '');
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* A human title for a doc name: `lit-muscle-memory-gotchas` ->
|
|
274
|
-
* `Lit Muscle Memory Gotchas`. Used in the resource listing so a model picks
|
|
275
|
-
* the right doc without reading it.
|
|
276
|
-
*
|
|
277
|
-
* @param {string} name
|
|
278
|
-
* @returns {string}
|
|
279
|
-
*/
|
|
280
|
-
function titleFor(name) {
|
|
281
|
-
if (name === 'AGENTS') return 'AGENTS.md (the framework contract + invariants)';
|
|
282
|
-
if (name === 'SKILL') return 'SKILL.md (the WebJs agent skill: how to build)';
|
|
283
|
-
return name
|
|
284
|
-
.split('-')
|
|
285
|
-
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
286
|
-
.join(' ');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* The corpus catalogue: every servable doc as `{ name, file, uri, title }`.
|
|
291
|
-
* Reads the docs dir listing (so it tracks the shipped set) plus the `AGENTS.md`
|
|
292
|
-
* contract. PURE given `deps`.
|
|
293
|
-
*
|
|
294
|
-
* @param {{ docsDir: string, agentsPath: string, listDir: (d: string) => string[], exists: (p: string) => boolean }} deps
|
|
295
|
-
* @returns {Array<{ name: string, file: string, uri: string, title: string }>}
|
|
296
|
-
*/
|
|
297
|
-
export function catalogue(deps) {
|
|
298
|
-
const { docsDir, agentsPath, skillPath, listDir, exists } = deps;
|
|
299
|
-
/** @type {Array<{ name: string, file: string, uri: string, title: string }>} */
|
|
300
|
-
const out = [];
|
|
301
|
-
if (exists(agentsPath)) {
|
|
302
|
-
out.push({ name: 'AGENTS', file: agentsPath, uri: `${DOCS_SCHEME}AGENTS`, title: titleFor('AGENTS') });
|
|
303
|
-
}
|
|
304
|
-
if (skillPath && exists(skillPath)) {
|
|
305
|
-
out.push({ name: 'SKILL', file: skillPath, uri: `${DOCS_SCHEME}SKILL`, title: titleFor('SKILL') });
|
|
306
|
-
}
|
|
307
|
-
let files = [];
|
|
308
|
-
try { files = listDir(docsDir).filter((f) => /\.md$/i.test(f)).sort(); } catch { files = []; }
|
|
309
|
-
for (const f of files) {
|
|
310
|
-
const name = docName(f);
|
|
311
|
-
out.push({ name, file: join(docsDir, f), uri: `${DOCS_SCHEME}${name}`, title: titleFor(name) });
|
|
312
|
-
}
|
|
313
|
-
return out;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* MCP `resources/list`: the corpus as resource descriptors.
|
|
318
|
-
*
|
|
319
|
-
* @param {object} deps
|
|
320
|
-
* @returns {Array<{ uri: string, name: string, title: string, mimeType: string }>}
|
|
321
|
-
*/
|
|
322
|
-
export function listResources(deps) {
|
|
323
|
-
return catalogue(deps).map((d) => ({
|
|
324
|
-
uri: d.uri,
|
|
325
|
-
name: d.name,
|
|
326
|
-
title: d.title,
|
|
327
|
-
mimeType: 'text/markdown',
|
|
328
|
-
}));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* MCP `resources/read`: the markdown text for a `webjs-docs://<name>` URI.
|
|
333
|
-
* Throws a clear Error for an unknown URI (the dispatcher maps it to a JSON-RPC
|
|
334
|
-
* error, never a crash).
|
|
335
|
-
*
|
|
336
|
-
* @param {object} deps
|
|
337
|
-
* @param {string} uri
|
|
338
|
-
* @returns {Promise<{ uri: string, mimeType: string, text: string }>}
|
|
339
|
-
*/
|
|
340
|
-
export async function readResource(deps, uri) {
|
|
341
|
-
const entry = catalogue(deps).find((d) => d.uri === uri);
|
|
342
|
-
if (!entry) throw new Error(`Unknown resource: ${uri}`);
|
|
343
|
-
const text = await deps.readFile(entry.file, 'utf8');
|
|
344
|
-
return { uri, mimeType: 'text/markdown', text };
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Extract a `## <heading>` section (heading line through the line before the
|
|
349
|
-
* next same-or-higher-level heading) from a markdown doc. Used to source the
|
|
350
|
-
* `init` primer from `AGENTS.md` rather than hand-duplicating it (so it cannot
|
|
351
|
-
* drift). Returns '' when the heading is absent.
|
|
352
|
-
*
|
|
353
|
-
* @param {string} md
|
|
354
|
-
* @param {RegExp} headingRe matches the section's heading LINE (e.g. /^##\s+Execution model/m)
|
|
355
|
-
* @returns {string}
|
|
356
|
-
*/
|
|
357
|
-
export function sectionByHeading(md, headingRe) {
|
|
358
|
-
const lines = md.split('\n');
|
|
359
|
-
let start = -1;
|
|
360
|
-
let level = 0;
|
|
361
|
-
for (let i = 0; i < lines.length; i++) {
|
|
362
|
-
if (headingRe.test(lines[i])) {
|
|
363
|
-
start = i;
|
|
364
|
-
level = (lines[i].match(/^#+/) || ['##'])[0].length;
|
|
365
|
-
break;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
if (start === -1) return '';
|
|
369
|
-
let end = lines.length;
|
|
370
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
371
|
-
const m = lines[i].match(/^(#+)\s/);
|
|
372
|
-
if (m && m[1].length <= level) { end = i; break; }
|
|
373
|
-
}
|
|
374
|
-
return lines.slice(start, end).join('\n').trim();
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* The `init` tool output: the "read first" orientation that fights the
|
|
379
|
-
* React/RSC mental model. Sources the EXECUTION MODEL + INVARIANTS sections
|
|
380
|
-
* from the shipped `AGENTS.md` (no hand-duplication), prepends a short router
|
|
381
|
-
* to the highest-value resources, and lists the corpus so the agent knows what
|
|
382
|
-
* else it can pull. PURE given `deps`.
|
|
383
|
-
*
|
|
384
|
-
* @param {object} deps
|
|
385
|
-
* @returns {Promise<string>}
|
|
386
|
-
*/
|
|
387
|
-
export async function initText(deps) {
|
|
388
|
-
let agents = '';
|
|
389
|
-
try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
|
|
390
|
-
const corpus = await corpusLine(deps);
|
|
391
|
-
const warning = await staleWarning(deps);
|
|
392
|
-
const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
|
|
393
|
-
const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
|
|
394
|
-
|
|
395
|
-
const cat = catalogue(deps);
|
|
396
|
-
const resourceList = cat.map((d) => `- \`${d.uri}\` (${d.title})`).join('\n');
|
|
397
|
-
|
|
398
|
-
const router = [
|
|
399
|
-
'You are about to write or edit a webjs app. Read this orientation FIRST, then',
|
|
400
|
-
'pull the specific docs you need via the `docs` tool or the `webjs-docs://*`',
|
|
401
|
-
'resources. webjs is web-components-first and looks like Lit + Rails, NOT React/Next:',
|
|
402
|
-
'there is NO RSC, no server/client component split, no `use client`. Components',
|
|
403
|
-
'hydrate (islands); pages and layouts do NOT hydrate. Signals are the default',
|
|
404
|
-
'state primitive. Server-only code lives behind the `.server.{js,ts}` boundary.',
|
|
405
|
-
'When writing a component, read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
406
|
-
'the Lit habits that break webjs SSR/reactivity each have a webjs-shaped fix there.',
|
|
407
|
-
'',
|
|
408
|
-
'webjs is buildless: the authored framework source is readable JSDoc in',
|
|
409
|
-
'`node_modules/@webjsdev/<pkg>/src`, and server-side that source runs directly.',
|
|
410
|
-
'(The one built artifact is the `@webjsdev/core` BROWSER bundle in `dist/`;',
|
|
411
|
-
'its authored source is still in `src/`.) When the docs do not answer something,',
|
|
412
|
-
'use the `source` tool to grep or read that real `src/` source (it skips `dist/`).',
|
|
413
|
-
].join('\n');
|
|
414
|
-
|
|
415
|
-
const parts = [
|
|
416
|
-
'# webjs: read first',
|
|
417
|
-
'',
|
|
418
|
-
// Provenance first, so an agent reading a months-old global install sees
|
|
419
|
-
// which docs it is holding before it reads a word of them (#1319).
|
|
420
|
-
...(warning ? [warning, ''] : []),
|
|
421
|
-
corpus,
|
|
422
|
-
'',
|
|
423
|
-
router,
|
|
424
|
-
'',
|
|
425
|
-
execModel || '(execution-model section unavailable)',
|
|
426
|
-
'',
|
|
427
|
-
invariants || '(invariants section unavailable)',
|
|
428
|
-
'',
|
|
429
|
-
'## Available docs (read via the `docs` tool or these resources)',
|
|
430
|
-
'',
|
|
431
|
-
resourceList || '(no docs bundled)',
|
|
432
|
-
];
|
|
433
|
-
return parts.join('\n');
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* The `docs` tool. With `topic` matching a corpus name, returns that doc's full
|
|
438
|
-
* text. With `query`, keyword-searches every doc and returns the matching lines
|
|
439
|
-
* (each tagged with its source URI and nearest heading). With neither, returns
|
|
440
|
-
* the topic index (the catalogue). PURE given `deps`.
|
|
441
|
-
*
|
|
442
|
-
* @param {object} deps
|
|
443
|
-
* @param {{ topic?: string, query?: string }} [args]
|
|
444
|
-
* @returns {Promise<string>}
|
|
445
|
-
*/
|
|
446
|
-
export async function searchDocs(deps, args) {
|
|
447
|
-
const { topic, query } = args || {};
|
|
448
|
-
const cat = catalogue(deps);
|
|
449
|
-
|
|
450
|
-
if (topic) {
|
|
451
|
-
const entry = cat.find((d) => d.name.toLowerCase() === String(topic).toLowerCase());
|
|
452
|
-
if (!entry) {
|
|
453
|
-
const names = cat.map((d) => d.name).join(', ');
|
|
454
|
-
return `Unknown topic "${topic}". Available topics: ${names}`;
|
|
455
|
-
}
|
|
456
|
-
return await deps.readFile(entry.file, 'utf8');
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (query) {
|
|
460
|
-
const q = String(query).toLowerCase();
|
|
461
|
-
const MAX_HITS = 40;
|
|
462
|
-
/** @type {string[]} */
|
|
463
|
-
const hits = [];
|
|
464
|
-
let capped = false;
|
|
465
|
-
outer: for (const entry of cat) {
|
|
466
|
-
let text = '';
|
|
467
|
-
try { text = await deps.readFile(entry.file, 'utf8'); } catch { continue; }
|
|
468
|
-
const lines = text.split('\n');
|
|
469
|
-
for (let i = 0; i < lines.length; i++) {
|
|
470
|
-
if (!lines[i].toLowerCase().includes(q)) continue;
|
|
471
|
-
// Check the cap BEFORE pushing, so `capped` means a match BEYOND the cap
|
|
472
|
-
// exists (exactly MAX_HITS matches is NOT a truncation, nothing dropped).
|
|
473
|
-
if (hits.length >= MAX_HITS) { capped = true; break outer; }
|
|
474
|
-
// Nearest preceding heading for context.
|
|
475
|
-
let heading = '';
|
|
476
|
-
for (let j = i; j >= 0; j--) {
|
|
477
|
-
if (/^#+\s/.test(lines[j])) { heading = lines[j].replace(/^#+\s/, ''); break; }
|
|
478
|
-
}
|
|
479
|
-
hits.push(`[${entry.uri}] ${heading ? heading + ': ' : ''}${lines[i].trim()}`);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (!hits.length) return `No matches for "${query}" in the webjs docs. Topics: ${cat.map((d) => d.name).join(', ')}`;
|
|
483
|
-
// Disclose truncation rather than silently capping (no silent caps).
|
|
484
|
-
if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; refine the query or open a doc with \`topic\`)`);
|
|
485
|
-
return hits.join('\n');
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// No args: the topic index.
|
|
489
|
-
return ['webjs docs topics (pass one as `topic`, or `query` to search):', '', ...cat.map((d) => `- ${d.name}: ${d.title}`)].join('\n');
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* The guided-workflow PROMPTS, built from the recipes. Each is a single-message
|
|
494
|
-
* prompt that hands the agent the canonical WebJs recipe plus the invariants it
|
|
495
|
-
* must not break, then tells it to pull `webjs-docs://recipes` for the full set.
|
|
496
|
-
* Static metadata; {@link getPrompt} fills the message text.
|
|
497
|
-
*/
|
|
498
|
-
export const PROMPTS = [
|
|
499
|
-
{ name: 'add_page', description: 'Scaffold a webjs page (app/<segment>/page.ts), the idiomatic way.', arguments: [{ name: 'route', description: 'URL path, e.g. /about', required: false }] },
|
|
500
|
-
{ name: 'add_dynamic_route', description: 'Scaffold a dynamic page reading params, e.g. app/users/[id]/page.ts.', arguments: [{ name: 'route', description: 'URL path with a [param], e.g. /users/[id]', required: false }] },
|
|
501
|
-
{ name: 'add_server_action', description: 'Scaffold a server action (.server.ts + use server) called from a component.', arguments: [{ name: 'feature', description: 'Feature/module name', required: false }] },
|
|
502
|
-
{ name: 'add_component', description: 'Scaffold an interactive WebComponent (signals, light DOM, register).', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. my-thing', required: false }] },
|
|
503
|
-
{ name: 'fetch_data_in_component', description: 'Fetch server data IN a component with async render() (co-located, first-paint data), plus webjs-suspense streaming and renderFallback.', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. user-profile', required: false }] },
|
|
504
|
-
{ name: 'add_module', description: 'Scaffold a modules/<feature>/ slice (actions/queries/components/utils).', arguments: [{ name: 'feature', description: 'Feature name', required: false }] },
|
|
505
|
-
];
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* The canonical recipe snippet + invariant reminders for each prompt. Kept
|
|
509
|
-
* compact on purpose: the prompt orients + shows the shape, then points at the
|
|
510
|
-
* full `webjs-docs://recipes` resource. The shapes mirror `.agents/skills/webjs/references/data-and-actions.md`.
|
|
511
|
-
*
|
|
512
|
-
* @type {Record<string, string>}
|
|
513
|
-
*/
|
|
514
|
-
const PROMPT_BODIES = {
|
|
515
|
-
add_page: [
|
|
516
|
-
'Add a webjs page. A page is `app/<segment>/page.ts` whose DEFAULT export is a',
|
|
517
|
-
'(possibly async) function returning a `TemplateResult`; it runs ONLY on the server.',
|
|
518
|
-
'',
|
|
519
|
-
'```ts',
|
|
520
|
-
"import { html } from '@webjsdev/core';",
|
|
521
|
-
'export default function About() {',
|
|
522
|
-
' return html`<h1>About</h1>`;',
|
|
523
|
-
'}',
|
|
524
|
-
'```',
|
|
525
|
-
'',
|
|
526
|
-
'Rules: the default export is a FUNCTION (invariant 6), it does NOT call render().',
|
|
527
|
-
'For interactivity, render a component tag inside it (pages do not hydrate).',
|
|
528
|
-
'Name metadata via a `metadata` / `generateMetadata` named export.',
|
|
529
|
-
].join('\n'),
|
|
530
|
-
add_dynamic_route: [
|
|
531
|
-
'Add a dynamic page. `app/users/[id]/page.ts` receives `{ params }`; fetch data',
|
|
532
|
-
'through a server action or query, never by importing the DB directly.',
|
|
533
|
-
'',
|
|
534
|
-
'```ts',
|
|
535
|
-
'export default async function User({ params }: { params: { id: string } }) {',
|
|
536
|
-
' const user = await getUser(params.id); // a `use server` action / query',
|
|
537
|
-
' return html`<h1>${user.name}</h1>`;',
|
|
538
|
-
'}',
|
|
539
|
-
'```',
|
|
540
|
-
'',
|
|
541
|
-
'Catch-all is `[...rest]`, optional catch-all `[[...rest]]`. Server-only data',
|
|
542
|
-
'access goes through `.server.{js,ts}`, never a direct import into the page.',
|
|
543
|
-
].join('\n'),
|
|
544
|
-
add_server_action: [
|
|
545
|
-
"Add a server action. A `*.server.ts` file with `'use server'` exports async",
|
|
546
|
-
'functions that round-trip serializer-safe values; a client import is rewritten',
|
|
547
|
-
'to a typed RPC stub (never hand-write fetch).',
|
|
548
|
-
'',
|
|
549
|
-
'```ts',
|
|
550
|
-
'// modules/<feature>/actions/<name>.server.ts',
|
|
551
|
-
"'use server';",
|
|
552
|
-
"import { db } from '../../../db/connection.server.ts';",
|
|
553
|
-
"import { things } from '../../../db/schema.server.ts';",
|
|
554
|
-
'export async function doThing(input: { name: string }) {',
|
|
555
|
-
" const name = String(input?.name || '').trim();",
|
|
556
|
-
" if (!name) return { success: false, error: 'name required', status: 400 };",
|
|
557
|
-
' const [row] = await db.insert(things).values({ name }).returning();',
|
|
558
|
-
' return { success: true, data: row };',
|
|
559
|
-
'}',
|
|
560
|
-
'```',
|
|
561
|
-
'',
|
|
562
|
-
'Return the `ActionResult<T>` envelope. Server-only code MUST stay in `.server.*`',
|
|
563
|
-
'(invariant 1). Call it from a component via a normal import.',
|
|
564
|
-
].join('\n'),
|
|
565
|
-
add_component: [
|
|
566
|
-
'Add an interactive WebComponent. One custom element per file; register at module',
|
|
567
|
-
'top level. Signals are the default state; read with `.get()` inside render().',
|
|
568
|
-
'',
|
|
569
|
-
'```ts',
|
|
570
|
-
"import { WebComponent, html, signal } from '@webjsdev/core';",
|
|
571
|
-
'export class MyThing extends WebComponent {',
|
|
572
|
-
' count = signal(0);',
|
|
573
|
-
' render() {',
|
|
574
|
-
' return html`<button @click=${() => this.count.set(this.count.get() + 1)}>',
|
|
575
|
-
' ${this.count.get()}</button>`;',
|
|
576
|
-
' }',
|
|
577
|
-
'}',
|
|
578
|
-
"MyThing.register('my-thing');",
|
|
579
|
-
'```',
|
|
580
|
-
'',
|
|
581
|
-
'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
|
|
582
|
-
'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
583
|
-
'a class-field initializer that overwrites a reactive accessor breaks reactivity',
|
|
584
|
-
'(declare reactive props via the `extends WebComponent({ … })` factory).',
|
|
585
|
-
].join('\n'),
|
|
586
|
-
fetch_data_in_component: [
|
|
587
|
-
'Fetch server data IN the component that needs it, with an async render() (#469).',
|
|
588
|
-
'No prop-drilling: SSR awaits the render, so the DATA is in the first paint.',
|
|
589
|
-
'',
|
|
590
|
-
'```ts',
|
|
591
|
-
'class UserProfile extends WebComponent({ uid: String }) {',
|
|
592
|
-
' async render() {',
|
|
593
|
-
' const u = await getUser(this.uid); // a `use server` action: real fn at SSR, RPC stub on the client',
|
|
594
|
-
' return html`<h3>${u.name}</h3>`;',
|
|
595
|
-
' }',
|
|
596
|
-
'}',
|
|
597
|
-
"UserProfile.register('user-profile');",
|
|
598
|
-
'```',
|
|
599
|
-
'',
|
|
600
|
-
'Decoupled model: SSR BLOCKS by default (real data, no fallback). The client',
|
|
601
|
-
're-fetch default is stale-while-revalidate. `renderFallback()` is the OPTIONAL',
|
|
602
|
-
're-fetch loading state (re-fetch only, NEVER first paint). Errors are isolated',
|
|
603
|
-
'per component by default (add `renderError()` only to customize). For SLOW data,',
|
|
604
|
-
'wrap in `<webjs-suspense .fallback=${html`...`}>` to STREAM it (fallback first).',
|
|
605
|
-
'Keep `Task` / signals for client-only data (it shows pending at SSR). Read',
|
|
606
|
-
'`webjs-docs://components` for the full decision guide and anti-patterns.',
|
|
607
|
-
].join('\n'),
|
|
608
|
-
add_module: [
|
|
609
|
-
'Add a feature module. `modules/<feature>/` holds `actions/*.server.ts` (mutations),',
|
|
610
|
-
'`queries/*.server.ts` (reads), `components/*.ts` (feature UI), `utils/*.ts` (pure),',
|
|
611
|
-
'`types.ts`. One function per action/query file. Routes stay thin: extract anything',
|
|
612
|
-
'over ~20 lines into a module action. Shared presentational primitives go in the',
|
|
613
|
-
'top-level `components/`, cross-cutting infra in `lib/*.server.ts`.',
|
|
614
|
-
].join('\n'),
|
|
615
|
-
};
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* MCP `prompts/get`: the messages for a guided-workflow prompt. Throws for an
|
|
619
|
-
* unknown name (mapped to a JSON-RPC error).
|
|
620
|
-
*
|
|
621
|
-
* @param {string} name
|
|
622
|
-
* @param {Record<string, string>} [args]
|
|
623
|
-
* @returns {{ description: string, messages: Array<{ role: string, content: { type: string, text: string } }> }}
|
|
624
|
-
*/
|
|
625
|
-
export function getPrompt(name, args) {
|
|
626
|
-
const meta = PROMPTS.find((p) => p.name === name);
|
|
627
|
-
const body = PROMPT_BODIES[name];
|
|
628
|
-
if (!meta || !body) throw new Error(`Unknown prompt: ${name}`);
|
|
629
|
-
|
|
630
|
-
// Fold any provided argument values in as a one-line hint at the top.
|
|
631
|
-
const provided = Object.entries(args || {}).filter(([, v]) => v != null && v !== '');
|
|
632
|
-
const argLine = provided.length
|
|
633
|
-
? `Context: ${provided.map(([k, v]) => `${k}=${v}`).join(', ')}.\n\n`
|
|
634
|
-
: '';
|
|
635
|
-
|
|
636
|
-
const text = `${argLine}${body}\n\nSee \`webjs-docs://SKILL\` for the full guide (it routes to the reference for each task) and \`webjs-docs://AGENTS\` for the invariants.`;
|
|
637
|
-
return {
|
|
638
|
-
description: meta.description,
|
|
639
|
-
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
640
|
-
};
|
|
641
|
-
}
|