@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 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 objectively broken code (a crash, a');
259
- console.log(' security leak, or a build/type-strip failure) and always');
260
- console.log(' runs. Project conventions (layout, style, process) are');
261
- console.log(' guidance in CONVENTIONS.md, not rules here.\n');
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'), `import { html, cspNonce } from '@webjsdev/core';
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'), `import { html } from '@webjsdev/core';
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
@@ -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
+ }