@webjsdev/cli 0.10.10 → 0.10.12
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/bin/webjs.js +6 -4
- package/lib/create.js +16 -2
- package/lib/mcp-docs.js +400 -0
- package/lib/mcp-source.js +244 -0
- package/lib/mcp.js +167 -18
- package/package.json +7 -2
- package/resources/AGENTS.md +404 -0
- package/resources/agent-docs/advanced.md +1090 -0
- package/resources/agent-docs/built-ins.md +367 -0
- package/resources/agent-docs/components.md +486 -0
- package/resources/agent-docs/configuration.md +207 -0
- package/resources/agent-docs/framework-dev.md +65 -0
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +456 -0
- package/resources/agent-docs/metadata.md +334 -0
- package/resources/agent-docs/recipes.md +440 -0
- package/resources/agent-docs/service-worker.md +100 -0
- package/resources/agent-docs/ssr-partial-nav-design.md +214 -0
- package/resources/agent-docs/styling.md +235 -0
- package/resources/agent-docs/testing.md +372 -0
- package/resources/agent-docs/typescript.md +334 -0
- package/templates/.dockerignore +6 -4
- package/templates/AGENTS.md +25 -10
- package/templates/CONVENTIONS.md +18 -1
package/bin/webjs.js
CHANGED
|
@@ -255,10 +255,12 @@ async function main() {
|
|
|
255
255
|
|
|
256
256
|
if (rest.includes('--rules')) {
|
|
257
257
|
console.log('webjs check, correctness rules:');
|
|
258
|
-
console.log(' Every rule catches
|
|
259
|
-
console.log(' security leak,
|
|
260
|
-
console.log('
|
|
261
|
-
console.log('
|
|
258
|
+
console.log(' Every rule catches code that is wrong to ship: a crash, a');
|
|
259
|
+
console.log(' security leak, a build/type-strip failure, or (the one');
|
|
260
|
+
console.log(' sentinel-based rule, no-scaffold-placeholder) unreplaced');
|
|
261
|
+
console.log(' scaffold example content. They always run. Project');
|
|
262
|
+
console.log(' conventions (layout, style, process) are guidance in');
|
|
263
|
+
console.log(' CONVENTIONS.md, not rules here.\n');
|
|
262
264
|
for (const r of RULES) {
|
|
263
265
|
console.log(` ${r.name.padEnd(30)} ${r.description}`);
|
|
264
266
|
}
|
package/lib/create.js
CHANGED
|
@@ -685,7 +685,8 @@ export type ActionResult<T> =
|
|
|
685
685
|
.replace(/`/g, '\\`')
|
|
686
686
|
.replace(/\$\{/g, '\\${');
|
|
687
687
|
|
|
688
|
-
await writeFile(join(appDir, 'app', 'layout.ts'),
|
|
688
|
+
await writeFile(join(appDir, 'app', 'layout.ts'), `// webjs-scaffold-placeholder. This is the example app chrome (brand, nav, content-width container). Adapt it to your app, then delete this line. webjs check fails while the marker remains.
|
|
689
|
+
import { html, cspNonce } from '@webjsdev/core';
|
|
689
690
|
import '@webjsdev/core/client-router';
|
|
690
691
|
import '../components/theme-toggle.ts';
|
|
691
692
|
// Webjs UI components are tiered:
|
|
@@ -846,11 +847,19 @@ ${SHADCN_THEME}
|
|
|
846
847
|
<span>${name}</span>
|
|
847
848
|
</a>
|
|
848
849
|
<nav class="flex gap-4 items-center">
|
|
850
|
+
<!-- Example nav. Replace with the real navigation for your app. -->
|
|
849
851
|
\${navLink('/', 'Home')}
|
|
850
852
|
<theme-toggle></theme-toggle>
|
|
851
853
|
</nav>
|
|
852
854
|
</header>
|
|
853
855
|
|
|
856
|
+
<!--
|
|
857
|
+
Content shell. The max-w-[760px] cap is a comfortable READING width,
|
|
858
|
+
right for prose, forms, and marketing. For a full-bleed app, dashboard,
|
|
859
|
+
or board, REPLACE it: widen the cap (for example max-w-[1400px]) or
|
|
860
|
+
drop the cap and mx-auto for an edge-to-edge layout. A wide layout left
|
|
861
|
+
inside the 760px reading column overflows into a horizontal scrollbar.
|
|
862
|
+
-->
|
|
854
863
|
<main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
|
|
855
864
|
\${children}
|
|
856
865
|
</main>
|
|
@@ -858,7 +867,8 @@ ${SHADCN_THEME}
|
|
|
858
867
|
}
|
|
859
868
|
`);
|
|
860
869
|
|
|
861
|
-
await writeFile(join(appDir, 'app', 'page.ts'),
|
|
870
|
+
await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real page, then delete this line. webjs check fails while the marker remains.
|
|
871
|
+
import { html } from '@webjsdev/core';
|
|
862
872
|
import { rubric, displayH1, accentLink } from '../lib/utils/ui.ts';
|
|
863
873
|
import { buttonClass } from '../components/ui/button.ts';
|
|
864
874
|
import { badgeClass } from '../components/ui/badge.ts';
|
|
@@ -1076,6 +1086,10 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1076
1086
|
Replace them with the app the user actually asked for. Don't ship
|
|
1077
1087
|
the scaffold's example User model or "Hello from …" page as the
|
|
1078
1088
|
final product.
|
|
1089
|
+
• This fresh app intentionally FAILS \`webjs check\` with two
|
|
1090
|
+
no-scaffold-placeholder violations (app/page.ts, app/layout.ts).
|
|
1091
|
+
That is the signal to replace the example content. Delete each
|
|
1092
|
+
marker comment line as you do, and the check goes green.
|
|
1079
1093
|
• Use Prisma + SQLite for app data. It's already wired up. Define
|
|
1080
1094
|
real models in prisma/schema.prisma and run \`webjs db migrate\`.
|
|
1081
1095
|
NEVER store app data in JSON files, in-memory arrays, or
|
package/lib/mcp-docs.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
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 `agent-docs/*.md` corpus
|
|
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/cli mcp` is self-contained): a published
|
|
18
|
+
* install reads the corpus bundled under `<cli>/resources/agent-docs` (copied
|
|
19
|
+
* at `prepack`, see `scripts/copy-mcp-resources.js`); a monorepo dev run falls
|
|
20
|
+
* back to the repo-root `agent-docs/`. {@link resolveDocsLocation} encodes that
|
|
21
|
+
* two-path lookup so source stays single (no committed duplicate docs).
|
|
22
|
+
*
|
|
23
|
+
* @module mcp-docs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
/** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
|
|
31
|
+
const DOCS_SCHEME = 'webjs-docs://';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
|
|
35
|
+
* path. Tries the BUNDLED location first (a published `@webjsdev/cli` ships
|
|
36
|
+
* `resources/agent-docs/` + `resources/AGENTS.md` via `prepack`), then falls
|
|
37
|
+
* back to the monorepo-root layout (`agent-docs/` + `AGENTS.md`) used in dev and
|
|
38
|
+
* tests. Returns `{ docsDir, agentsPath }`; either path may not exist, callers
|
|
39
|
+
* fail soft (an empty corpus is valid, never a crash).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
|
|
42
|
+
* @returns {{ docsDir: string, agentsPath: string }}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveDocsLocation(moduleUrl) {
|
|
45
|
+
const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
|
|
46
|
+
const cliRoot = resolve(here, '..'); // packages/cli/lib -> packages/cli
|
|
47
|
+
const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
|
|
48
|
+
|
|
49
|
+
const bundledDocs = join(cliRoot, 'resources', 'agent-docs');
|
|
50
|
+
if (existsSync(bundledDocs)) {
|
|
51
|
+
return { docsDir: bundledDocs, agentsPath: join(cliRoot, 'resources', 'AGENTS.md') };
|
|
52
|
+
}
|
|
53
|
+
return { docsDir: join(repoRoot, 'agent-docs'), agentsPath: join(repoRoot, 'AGENTS.md') };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
|
|
58
|
+
* `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} file
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function docName(file) {
|
|
64
|
+
return file.replace(/\.md$/i, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A human title for a doc name: `lit-muscle-memory-gotchas` ->
|
|
69
|
+
* `Lit Muscle Memory Gotchas`. Used in the resource listing so a model picks
|
|
70
|
+
* the right doc without reading it.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} name
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function titleFor(name) {
|
|
76
|
+
if (name === 'AGENTS') return 'AGENTS.md (the framework contract + invariants)';
|
|
77
|
+
return name
|
|
78
|
+
.split('-')
|
|
79
|
+
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
|
|
80
|
+
.join(' ');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The corpus catalogue: every servable doc as `{ name, file, uri, title }`.
|
|
85
|
+
* Reads the docs dir listing (so it tracks the shipped set) plus the `AGENTS.md`
|
|
86
|
+
* contract. PURE given `deps`.
|
|
87
|
+
*
|
|
88
|
+
* @param {{ docsDir: string, agentsPath: string, listDir: (d: string) => string[], exists: (p: string) => boolean }} deps
|
|
89
|
+
* @returns {Array<{ name: string, file: string, uri: string, title: string }>}
|
|
90
|
+
*/
|
|
91
|
+
export function catalogue(deps) {
|
|
92
|
+
const { docsDir, agentsPath, listDir, exists } = deps;
|
|
93
|
+
/** @type {Array<{ name: string, file: string, uri: string, title: string }>} */
|
|
94
|
+
const out = [];
|
|
95
|
+
if (exists(agentsPath)) {
|
|
96
|
+
out.push({ name: 'AGENTS', file: agentsPath, uri: `${DOCS_SCHEME}AGENTS`, title: titleFor('AGENTS') });
|
|
97
|
+
}
|
|
98
|
+
let files = [];
|
|
99
|
+
try { files = listDir(docsDir).filter((f) => /\.md$/i.test(f)).sort(); } catch { files = []; }
|
|
100
|
+
for (const f of files) {
|
|
101
|
+
const name = docName(f);
|
|
102
|
+
out.push({ name, file: join(docsDir, f), uri: `${DOCS_SCHEME}${name}`, title: titleFor(name) });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* MCP `resources/list`: the corpus as resource descriptors.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} deps
|
|
111
|
+
* @returns {Array<{ uri: string, name: string, title: string, mimeType: string }>}
|
|
112
|
+
*/
|
|
113
|
+
export function listResources(deps) {
|
|
114
|
+
return catalogue(deps).map((d) => ({
|
|
115
|
+
uri: d.uri,
|
|
116
|
+
name: d.name,
|
|
117
|
+
title: d.title,
|
|
118
|
+
mimeType: 'text/markdown',
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* MCP `resources/read`: the markdown text for a `webjs-docs://<name>` URI.
|
|
124
|
+
* Throws a clear Error for an unknown URI (the dispatcher maps it to a JSON-RPC
|
|
125
|
+
* error, never a crash).
|
|
126
|
+
*
|
|
127
|
+
* @param {object} deps
|
|
128
|
+
* @param {string} uri
|
|
129
|
+
* @returns {Promise<{ uri: string, mimeType: string, text: string }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function readResource(deps, uri) {
|
|
132
|
+
const entry = catalogue(deps).find((d) => d.uri === uri);
|
|
133
|
+
if (!entry) throw new Error(`Unknown resource: ${uri}`);
|
|
134
|
+
const text = await deps.readFile(entry.file, 'utf8');
|
|
135
|
+
return { uri, mimeType: 'text/markdown', text };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract a `## <heading>` section (heading line through the line before the
|
|
140
|
+
* next same-or-higher-level heading) from a markdown doc. Used to source the
|
|
141
|
+
* `init` primer from `AGENTS.md` rather than hand-duplicating it (so it cannot
|
|
142
|
+
* drift). Returns '' when the heading is absent.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} md
|
|
145
|
+
* @param {RegExp} headingRe matches the section's heading LINE (e.g. /^##\s+Execution model/m)
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function sectionByHeading(md, headingRe) {
|
|
149
|
+
const lines = md.split('\n');
|
|
150
|
+
let start = -1;
|
|
151
|
+
let level = 0;
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
if (headingRe.test(lines[i])) {
|
|
154
|
+
start = i;
|
|
155
|
+
level = (lines[i].match(/^#+/) || ['##'])[0].length;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (start === -1) return '';
|
|
160
|
+
let end = lines.length;
|
|
161
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
162
|
+
const m = lines[i].match(/^(#+)\s/);
|
|
163
|
+
if (m && m[1].length <= level) { end = i; break; }
|
|
164
|
+
}
|
|
165
|
+
return lines.slice(start, end).join('\n').trim();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The `init` tool output: the "read first" orientation that fights the
|
|
170
|
+
* React/RSC mental model. Sources the EXECUTION MODEL + INVARIANTS sections
|
|
171
|
+
* from the shipped `AGENTS.md` (no hand-duplication), prepends a short router
|
|
172
|
+
* to the highest-value resources, and lists the corpus so the agent knows what
|
|
173
|
+
* else it can pull. PURE given `deps`.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} deps
|
|
176
|
+
* @returns {Promise<string>}
|
|
177
|
+
*/
|
|
178
|
+
export async function initText(deps) {
|
|
179
|
+
let agents = '';
|
|
180
|
+
try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
|
|
181
|
+
const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
|
|
182
|
+
const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
|
|
183
|
+
|
|
184
|
+
const cat = catalogue(deps);
|
|
185
|
+
const resourceList = cat.map((d) => `- \`${d.uri}\` (${d.title})`).join('\n');
|
|
186
|
+
|
|
187
|
+
const router = [
|
|
188
|
+
'You are about to write or edit a webjs app. Read this orientation FIRST, then',
|
|
189
|
+
'pull the specific docs you need via the `docs` tool or the `webjs-docs://*`',
|
|
190
|
+
'resources. webjs is web-components-first and looks like Lit + Rails, NOT React/Next:',
|
|
191
|
+
'there is NO RSC, no server/client component split, no `use client`. Components',
|
|
192
|
+
'hydrate (islands); pages and layouts do NOT hydrate. Signals are the default',
|
|
193
|
+
'state primitive. Server-only code lives behind the `.server.{js,ts}` boundary.',
|
|
194
|
+
'When writing a component, read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
195
|
+
'the Lit habits that break webjs SSR/reactivity each have a webjs-shaped fix there.',
|
|
196
|
+
'',
|
|
197
|
+
'webjs is buildless: the authored framework source is readable JSDoc in',
|
|
198
|
+
'`node_modules/@webjsdev/<pkg>/src`, and server-side that source runs directly.',
|
|
199
|
+
'(The one built artifact is the `@webjsdev/core` BROWSER bundle in `dist/`;',
|
|
200
|
+
'its authored source is still in `src/`.) When the docs do not answer something,',
|
|
201
|
+
'use the `source` tool to grep or read that real `src/` source (it skips `dist/`).',
|
|
202
|
+
].join('\n');
|
|
203
|
+
|
|
204
|
+
const parts = [
|
|
205
|
+
'# webjs: read first',
|
|
206
|
+
'',
|
|
207
|
+
router,
|
|
208
|
+
'',
|
|
209
|
+
execModel || '(execution-model section unavailable)',
|
|
210
|
+
'',
|
|
211
|
+
invariants || '(invariants section unavailable)',
|
|
212
|
+
'',
|
|
213
|
+
'## Available docs (read via the `docs` tool or these resources)',
|
|
214
|
+
'',
|
|
215
|
+
resourceList || '(no docs bundled)',
|
|
216
|
+
];
|
|
217
|
+
return parts.join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The `docs` tool. With `topic` matching a corpus name, returns that doc's full
|
|
222
|
+
* text. With `query`, keyword-searches every doc and returns the matching lines
|
|
223
|
+
* (each tagged with its source URI and nearest heading). With neither, returns
|
|
224
|
+
* the topic index (the catalogue). PURE given `deps`.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} deps
|
|
227
|
+
* @param {{ topic?: string, query?: string }} [args]
|
|
228
|
+
* @returns {Promise<string>}
|
|
229
|
+
*/
|
|
230
|
+
export async function searchDocs(deps, args) {
|
|
231
|
+
const { topic, query } = args || {};
|
|
232
|
+
const cat = catalogue(deps);
|
|
233
|
+
|
|
234
|
+
if (topic) {
|
|
235
|
+
const entry = cat.find((d) => d.name.toLowerCase() === String(topic).toLowerCase());
|
|
236
|
+
if (!entry) {
|
|
237
|
+
const names = cat.map((d) => d.name).join(', ');
|
|
238
|
+
return `Unknown topic "${topic}". Available topics: ${names}`;
|
|
239
|
+
}
|
|
240
|
+
return await deps.readFile(entry.file, 'utf8');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (query) {
|
|
244
|
+
const q = String(query).toLowerCase();
|
|
245
|
+
const MAX_HITS = 40;
|
|
246
|
+
/** @type {string[]} */
|
|
247
|
+
const hits = [];
|
|
248
|
+
let capped = false;
|
|
249
|
+
outer: for (const entry of cat) {
|
|
250
|
+
let text = '';
|
|
251
|
+
try { text = await deps.readFile(entry.file, 'utf8'); } catch { continue; }
|
|
252
|
+
const lines = text.split('\n');
|
|
253
|
+
for (let i = 0; i < lines.length; i++) {
|
|
254
|
+
if (!lines[i].toLowerCase().includes(q)) continue;
|
|
255
|
+
// Check the cap BEFORE pushing, so `capped` means a match BEYOND the cap
|
|
256
|
+
// exists (exactly MAX_HITS matches is NOT a truncation, nothing dropped).
|
|
257
|
+
if (hits.length >= MAX_HITS) { capped = true; break outer; }
|
|
258
|
+
// Nearest preceding heading for context.
|
|
259
|
+
let heading = '';
|
|
260
|
+
for (let j = i; j >= 0; j--) {
|
|
261
|
+
if (/^#+\s/.test(lines[j])) { heading = lines[j].replace(/^#+\s/, ''); break; }
|
|
262
|
+
}
|
|
263
|
+
hits.push(`[${entry.uri}] ${heading ? heading + ': ' : ''}${lines[i].trim()}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!hits.length) return `No matches for "${query}" in the webjs docs. Topics: ${cat.map((d) => d.name).join(', ')}`;
|
|
267
|
+
// Disclose truncation rather than silently capping (no silent caps).
|
|
268
|
+
if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; refine the query or open a doc with \`topic\`)`);
|
|
269
|
+
return hits.join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No args: the topic index.
|
|
273
|
+
return ['webjs docs topics (pass one as `topic`, or `query` to search):', '', ...cat.map((d) => `- ${d.name}: ${d.title}`)].join('\n');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The guided-workflow PROMPTS, built from the recipes. Each is a single-message
|
|
278
|
+
* prompt that hands the agent the canonical webjs recipe plus the invariants it
|
|
279
|
+
* must not break, then tells it to pull `webjs-docs://recipes` for the full set.
|
|
280
|
+
* Static metadata; {@link getPrompt} fills the message text.
|
|
281
|
+
*/
|
|
282
|
+
export const PROMPTS = [
|
|
283
|
+
{ 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 }] },
|
|
284
|
+
{ 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 }] },
|
|
285
|
+
{ 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 }] },
|
|
286
|
+
{ 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 }] },
|
|
287
|
+
{ name: 'add_module', description: 'Scaffold a modules/<feature>/ slice (actions/queries/components/utils).', arguments: [{ name: 'feature', description: 'Feature name', required: false }] },
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The canonical recipe snippet + invariant reminders for each prompt. Kept
|
|
292
|
+
* compact on purpose: the prompt orients + shows the shape, then points at the
|
|
293
|
+
* full `webjs-docs://recipes` resource. The shapes mirror `agent-docs/recipes.md`.
|
|
294
|
+
*
|
|
295
|
+
* @type {Record<string, string>}
|
|
296
|
+
*/
|
|
297
|
+
const PROMPT_BODIES = {
|
|
298
|
+
add_page: [
|
|
299
|
+
'Add a webjs page. A page is `app/<segment>/page.ts` whose DEFAULT export is a',
|
|
300
|
+
'(possibly async) function returning a `TemplateResult`; it runs ONLY on the server.',
|
|
301
|
+
'',
|
|
302
|
+
'```ts',
|
|
303
|
+
"import { html } from '@webjsdev/core';",
|
|
304
|
+
'export default function About() {',
|
|
305
|
+
' return html`<h1>About</h1>`;',
|
|
306
|
+
'}',
|
|
307
|
+
'```',
|
|
308
|
+
'',
|
|
309
|
+
'Rules: the default export is a FUNCTION (invariant 6), it does NOT call render().',
|
|
310
|
+
'For interactivity, render a component tag inside it (pages do not hydrate).',
|
|
311
|
+
'Name metadata via a `metadata` / `generateMetadata` named export.',
|
|
312
|
+
].join('\n'),
|
|
313
|
+
add_dynamic_route: [
|
|
314
|
+
'Add a dynamic page. `app/users/[id]/page.ts` receives `{ params }`; fetch data',
|
|
315
|
+
'through a server action or query, never by importing the DB directly.',
|
|
316
|
+
'',
|
|
317
|
+
'```ts',
|
|
318
|
+
'export default async function User({ params }: { params: { id: string } }) {',
|
|
319
|
+
' const user = await getUser(params.id); // a `use server` action / query',
|
|
320
|
+
' return html`<h1>${user.name}</h1>`;',
|
|
321
|
+
'}',
|
|
322
|
+
'```',
|
|
323
|
+
'',
|
|
324
|
+
'Catch-all is `[...rest]`, optional catch-all `[[...rest]]`. Server-only data',
|
|
325
|
+
'access goes through `.server.{js,ts}`, never a direct import into the page.',
|
|
326
|
+
].join('\n'),
|
|
327
|
+
add_server_action: [
|
|
328
|
+
"Add a server action. A `*.server.ts` file with `'use server'` exports async",
|
|
329
|
+
'functions that round-trip serializer-safe values; a client import is rewritten',
|
|
330
|
+
'to a typed RPC stub (never hand-write fetch).',
|
|
331
|
+
'',
|
|
332
|
+
'```ts',
|
|
333
|
+
'// modules/<feature>/actions/<name>.server.ts',
|
|
334
|
+
"'use server';",
|
|
335
|
+
"import { prisma } from '../../../lib/prisma.server.ts';",
|
|
336
|
+
'export async function doThing(input: { name: string }) {',
|
|
337
|
+
" const name = String(input?.name || '').trim();",
|
|
338
|
+
" if (!name) return { success: false, error: 'name required', status: 400 };",
|
|
339
|
+
' return { success: true, data: await prisma.thing.create({ data: { name } }) };',
|
|
340
|
+
'}',
|
|
341
|
+
'```',
|
|
342
|
+
'',
|
|
343
|
+
'Return the `ActionResult<T>` envelope. Server-only code MUST stay in `.server.*`',
|
|
344
|
+
'(invariant 1). Call it from a component via a normal import.',
|
|
345
|
+
].join('\n'),
|
|
346
|
+
add_component: [
|
|
347
|
+
'Add an interactive WebComponent. One custom element per file; register at module',
|
|
348
|
+
'top level. Signals are the default state; read with `.get()` inside render().',
|
|
349
|
+
'',
|
|
350
|
+
'```ts',
|
|
351
|
+
"import { WebComponent, html, signal } from '@webjsdev/core';",
|
|
352
|
+
'export class MyThing extends WebComponent {',
|
|
353
|
+
' count = signal(0);',
|
|
354
|
+
' render() {',
|
|
355
|
+
' return html`<button @click=${() => this.count.set(this.count.get() + 1)}>',
|
|
356
|
+
' ${this.count.get()}</button>`;',
|
|
357
|
+
' }',
|
|
358
|
+
'}',
|
|
359
|
+
"MyThing.register('my-thing');",
|
|
360
|
+
'```',
|
|
361
|
+
'',
|
|
362
|
+
'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
|
|
363
|
+
'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
|
|
364
|
+
'a class-field initializer that overwrites a reactive accessor breaks reactivity',
|
|
365
|
+
'(use `declare` + `static properties`).',
|
|
366
|
+
].join('\n'),
|
|
367
|
+
add_module: [
|
|
368
|
+
'Add a feature module. `modules/<feature>/` holds `actions/*.server.ts` (mutations),',
|
|
369
|
+
'`queries/*.server.ts` (reads), `components/*.ts` (feature UI), `utils/*.ts` (pure),',
|
|
370
|
+
'`types.ts`. One function per action/query file. Routes stay thin: extract anything',
|
|
371
|
+
'over ~20 lines into a module action. Shared presentational primitives go in the',
|
|
372
|
+
'top-level `components/`, cross-cutting infra in `lib/*.server.ts`.',
|
|
373
|
+
].join('\n'),
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* MCP `prompts/get`: the messages for a guided-workflow prompt. Throws for an
|
|
378
|
+
* unknown name (mapped to a JSON-RPC error).
|
|
379
|
+
*
|
|
380
|
+
* @param {string} name
|
|
381
|
+
* @param {Record<string, string>} [args]
|
|
382
|
+
* @returns {{ description: string, messages: Array<{ role: string, content: { type: string, text: string } }> }}
|
|
383
|
+
*/
|
|
384
|
+
export function getPrompt(name, args) {
|
|
385
|
+
const meta = PROMPTS.find((p) => p.name === name);
|
|
386
|
+
const body = PROMPT_BODIES[name];
|
|
387
|
+
if (!meta || !body) throw new Error(`Unknown prompt: ${name}`);
|
|
388
|
+
|
|
389
|
+
// Fold any provided argument values in as a one-line hint at the top.
|
|
390
|
+
const provided = Object.entries(args || {}).filter(([, v]) => v != null && v !== '');
|
|
391
|
+
const argLine = provided.length
|
|
392
|
+
? `Context: ${provided.map(([k, v]) => `${k}=${v}`).join(', ')}.\n\n`
|
|
393
|
+
: '';
|
|
394
|
+
|
|
395
|
+
const text = `${argLine}${body}\n\nSee \`webjs-docs://recipes\` for the full recipe set and \`webjs-docs://AGENTS\` for the invariants.`;
|
|
396
|
+
return {
|
|
397
|
+
description: meta.description,
|
|
398
|
+
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
399
|
+
};
|
|
400
|
+
}
|