@waniwani/kit 0.1.0 → 0.1.3

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/cli/init.mjs ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * `waniwani init`, the first command anyone runs.
3
+ *
4
+ * What it writes is an app folder that already passes `waniwani check` and
5
+ * already renders something: a config, a tool, and the widget that displays what
6
+ * the tool returned. The point of scaffolding a working pair instead of an empty
7
+ * folder is that the tool-to-widget hand-off is the one piece of this framework
8
+ * nobody guesses correctly from the type signatures.
9
+ *
10
+ * waniwani init my-app create my-app/ and scaffold in it
11
+ * waniwani init . scaffold in the current directory
12
+ * waniwani init ask for a name, and put the app where the
13
+ * answer says: a name of its own creates
14
+ * ./<name>/, the offered default (the current
15
+ * folder's name) scaffolds in place
16
+ *
17
+ * --name <name> the MCP server name, default the directory name
18
+ * --minimal config and one tool, no widget
19
+ * --yes take every default, ask nothing
20
+ * --no-install skip the dependency install
21
+ * --force overwrite app files that are already there
22
+ *
23
+ * Running it inside a repo that already has files is expected and supported.
24
+ * A `package.json` is merged rather than replaced, a `.gitignore` gains the
25
+ * lines it lacks, and a `README.md` or `.env.example` that exists is left
26
+ * alone. Only the app's own source files count as a collision, and those stop
27
+ * the command until `--force` says otherwise.
28
+ */
29
+
30
+ import { spawn } from "node:child_process";
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
+ import { basename, dirname, join, relative } from "node:path";
33
+ import { createInterface } from "node:readline/promises";
34
+ import { fileURLToPath } from "node:url";
35
+ import { bold, dim, green, red, yellow } from "./log.mjs";
36
+
37
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
38
+ const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"));
39
+
40
+ /** A directory name as an MCP server name: `My App` becomes `my-app`. */
41
+ function slugify(input) {
42
+ const slug = input
43
+ .trim()
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/^-+|-+$/g, "");
47
+ return slug || "my-app";
48
+ }
49
+
50
+ /** `my-app` becomes `My app`, for the human-facing title. */
51
+ function titleize(slug) {
52
+ const words = slug.replace(/[-_]+/g, " ");
53
+ return words.charAt(0).toUpperCase() + words.slice(1);
54
+ }
55
+
56
+ /**
57
+ * A title, made safe to drop into the generated TypeScript and Markdown.
58
+ * Backticks, quotes, backslashes and `$` are the characters that would end a
59
+ * string or a template literal early, and a product name needs none of them.
60
+ */
61
+ function cleanTitle(input) {
62
+ return input
63
+ .replace(/[`"'\\$]/g, "")
64
+ .replace(/\s+/g, " ")
65
+ .trim();
66
+ }
67
+
68
+ /**
69
+ * A peer range is a floor, `>=19`, and a floor in an app's dependencies installs
70
+ * the next major on the day it lands. Cap it. Anything already ranged, `^4`,
71
+ * passes through as it is.
72
+ */
73
+ function installable(name, range) {
74
+ if (!range) {
75
+ throw new Error(`@waniwani/kit declares no peer range for ${name}: this package's manifest moved`);
76
+ }
77
+ const floor = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(range.trim());
78
+ return floor ? `^${floor[1]}.${floor[2] ?? 0}.${floor[3] ?? 0}` : range;
79
+ }
80
+
81
+ /**
82
+ * What a new app depends on.
83
+ *
84
+ * Every version is read off this package's own manifest: `@waniwani/kit` at the
85
+ * version of the CLI doing the scaffolding, `@waniwani/sdk` at the version this
86
+ * runtime is built against, and react, react-dom and zod at the peer ranges this
87
+ * package declares. A scaffold that wrote its own numbers here would be the one
88
+ * file in the folder that can be wrong the day it is created.
89
+ */
90
+ function dependencies() {
91
+ const peers = MANIFEST.peerDependencies ?? {};
92
+ return {
93
+ "@waniwani/kit": `^${MANIFEST.version}`,
94
+ "@waniwani/sdk": MANIFEST.dependencies["@waniwani/sdk"],
95
+ react: installable("react", peers.react),
96
+ "react-dom": installable("react-dom", peers["react-dom"]),
97
+ zod: installable("zod", peers.zod),
98
+ };
99
+ }
100
+
101
+ // ------------------------------------------------------------ scaffold content
102
+
103
+ /**
104
+ * The tool name and the widget name the scaffold uses. They appear in five
105
+ * files, including inside prose the model reads, so they are named once.
106
+ */
107
+ const TOOL = "search-products";
108
+ const WIDGET = "product-list";
109
+
110
+ function packageJson(app) {
111
+ return `${JSON.stringify(
112
+ {
113
+ name: app.name,
114
+ private: true,
115
+ type: "module",
116
+ scripts: {
117
+ check: "waniwani check",
118
+ dev: "waniwani dev",
119
+ build: "waniwani build",
120
+ start: "waniwani start",
121
+ },
122
+ dependencies: dependencies(),
123
+ },
124
+ null,
125
+ 2,
126
+ )}\n`;
127
+ }
128
+
129
+ function appConfig(app) {
130
+ return `import { defineApp } from "@waniwani/kit";
131
+
132
+ export default defineApp({
133
+ // The MCP server name. Hosts show \`title\` to humans and use this one as the id.
134
+ name: ${JSON.stringify(app.name)},
135
+ title: ${JSON.stringify(app.title)},
136
+ });
137
+ `;
138
+ }
139
+
140
+ function tool() {
141
+ return `import { defineTool } from "@waniwani/kit";
142
+ import { z } from "zod";
143
+
144
+ /**
145
+ * The filename is the tool name, so this file is \`${TOOL}\`. Rename the
146
+ * file and the tool renames with it.
147
+ *
148
+ * Swap CATALOGUE for whatever answers the question for real: a fetch, a
149
+ * database, an internal API. \`run\` may be async.
150
+ */
151
+ const CATALOGUE = [
152
+ { id: "aeron", name: "Aeron chair", price: 1290, blurb: "Mesh task chair, twelve-year warranty." },
153
+ { id: "sayl", name: "Sayl chair", price: 545, blurb: "Suspension back, the light one." },
154
+ { id: "nevi", name: "Nevi sit-stand desk", price: 890, blurb: "Electric, 70 to 120 cm." },
155
+ { id: "ollin", name: "Ollin monitor arm", price: 235, blurb: "Single arm, holds up to 9 kg." },
156
+ ];
157
+
158
+ export default defineTool({
159
+ // Shown to humans in connector UIs.
160
+ title: "Search the catalogue",
161
+ // The only thing the model reads before deciding to call this, so it says
162
+ // when to call it and what not to do instead.
163
+ description:
164
+ "Find products matching what the shopper asked for. Call this before naming any product or quoting any price, and never answer either from memory. Pass the shopper's own words as the query.",
165
+ // Zod shapes, written as plain objects instead of z.object({ ... }).
166
+ input: {
167
+ query: z.string().describe("What the shopper asked for, in their words, e.g. 'a chair under 600'."),
168
+ },
169
+ output: {
170
+ products: z.array(
171
+ z.object({
172
+ id: z.string(),
173
+ name: z.string(),
174
+ price: z.number().describe("Price in euros."),
175
+ blurb: z.string(),
176
+ }),
177
+ ),
178
+ },
179
+ // Becomes MCP annotations. This tool reads and does nothing else.
180
+ hints: { readOnly: true },
181
+ run: ({ query }) => {
182
+ const terms = query.toLowerCase().split(/\\s+/).filter(Boolean);
183
+ const matched = CATALOGUE.filter((product) =>
184
+ terms.some((term) => \`\${product.name} \${product.blurb}\`.toLowerCase().includes(term)),
185
+ );
186
+ // The whole catalogue when nothing matched, so an early conversation has
187
+ // something on screen while you are still wiring this up.
188
+ return { products: matched.length > 0 ? matched : CATALOGUE };
189
+ },
190
+ });
191
+ `;
192
+ }
193
+
194
+ function widgetContract() {
195
+ return `import { defineWidget } from "@waniwani/kit";
196
+ import { z } from "zod";
197
+
198
+ const product = z.object({
199
+ id: z.string(),
200
+ name: z.string(),
201
+ price: z.number().describe("Price in euros."),
202
+ blurb: z.string().describe("One line about the product."),
203
+ });
204
+
205
+ /**
206
+ * The folder name is the tool name, so this widget is \`${WIDGET}\`.
207
+ *
208
+ * \`data\` is one schema doing three jobs: the tool's input, its structured
209
+ * output, and the props \`useWidget()\` hands ui.tsx. Server and UI cannot drift.
210
+ *
211
+ * This file is imported by the server and by the browser bundle, so it stays
212
+ * free of React and CSS. The component sits next to it in ui.tsx.
213
+ */
214
+ export default defineWidget({
215
+ title: "Product list",
216
+ description:
217
+ "Show the product cards. Call this once ${TOOL} has returned products, passing them through unmodified. Frame it in one short sentence before calling, e.g. \\"Here's what fits.\\" The widget renders every name and price itself, so do NOT list them in text.",
218
+ data: {
219
+ query: z.string().describe("What the shopper asked for. Shown as the heading."),
220
+ products: z.array(product).describe("Products returned by ${TOOL}, unmodified."),
221
+ },
222
+ hints: { readOnly: true },
223
+ // Text handed to the model alongside the rendered widget. Use it to say what
224
+ // the model should not repeat, and what it should wait for.
225
+ llmText: (data) =>
226
+ \`The product list is on screen with \${data.products.length} products. It renders every name and price itself, so do NOT repeat them in text.
227
+
228
+ Wait for the shopper to pick one, then answer about that product.\`,
229
+ });
230
+ `;
231
+ }
232
+
233
+ function widgetUi() {
234
+ return `import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
235
+ import widget from "./widget.js";
236
+
237
+ const euros = (value: number) =>
238
+ new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }).format(value);
239
+
240
+ export default function ProductList() {
241
+ // Typed off the widget's own \`data\` schema. No generated helpers, no server
242
+ // type import.
243
+ const { data } = useWidget(widget);
244
+ const sendFollowUp = useSendFollowUpMessage();
245
+
246
+ // The host hands the colour scheme to the view instead of to the browser, so
247
+ // \`prefers-color-scheme\` is the wrong signal and Tailwind's \`dark:\` variant is
248
+ // wired to a \`dark\` class (see the template's src/index.css). Every widget puts
249
+ // that class on its own root: a view is its own bundle in its own iframe, so
250
+ // there is no shared ancestor to hang it off.
251
+ const { theme } = useLayout();
252
+ const root = theme === "dark" ? "dark" : "";
253
+
254
+ // \`data\` arrives as soon as the host has the tool input, which on most hosts is
255
+ // before the server has responded. Render optimistically.
256
+ if (!data) {
257
+ return <div className={\`\${root} font-sans text-sm text-slate-500\`}>Loading…</div>;
258
+ }
259
+
260
+ return (
261
+ <div className={\`\${root} font-sans text-slate-900 dark:text-slate-100\`}>
262
+ <h1 className="mb-3 text-lg font-semibold tracking-tight">{data.query}</h1>
263
+
264
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2.5">
265
+ {data.products.map((product) => (
266
+ <button
267
+ type="button"
268
+ key={product.id}
269
+ // A click becomes a message from the shopper, which is what moves
270
+ // the conversation on.
271
+ onClick={() => sendFollowUp(\`Tell me more about the \${product.name}.\`)}
272
+ className="flex cursor-pointer flex-col items-start gap-1 rounded-2xl border-[1.5px] border-slate-200 bg-white p-3.5 text-left transition duration-150 hover:-translate-y-px hover:border-slate-400 hover:shadow-lg hover:shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-500"
273
+ // What the model reads in place of the pixels.
274
+ data-llm={\`\${product.name}, \${euros(product.price)}: \${product.blurb}\`}
275
+ >
276
+ <span className="text-[22px] font-bold tracking-tight">{euros(product.price)}</span>
277
+ <span className="font-semibold">{product.name}</span>
278
+ <span className="text-[13px] text-slate-500 dark:text-slate-400">{product.blurb}</span>
279
+ </button>
280
+ ))}
281
+ </div>
282
+ </div>
283
+ );
284
+ }
285
+ `;
286
+ }
287
+
288
+ function envExample() {
289
+ return `# Optional. Without it the app still runs: flows use MemoryKvStore and
290
+ # withWaniwani degrades to a no-op. With it, flow state is hosted and tracking
291
+ # reaches app.waniwani.ai.
292
+ WANIWANI_API_KEY=
293
+ WANIWANI_PUBLIC_KEY=
294
+ `;
295
+ }
296
+
297
+ function gitignore() {
298
+ return `node_modules/
299
+ .waniwani/
300
+ .env
301
+ .env.local
302
+ `;
303
+ }
304
+
305
+ function readme(app) {
306
+ return `# ${app.title}
307
+
308
+ An MCP app built with [@waniwani/kit](https://www.npmjs.com/package/@waniwani/kit).
309
+ You own the folders below. The server, the transport, the bundling and the deploy
310
+ files are the kit's.
311
+
312
+ \`\`\`
313
+ waniwani.config.ts the app's name and title, plus optional instructions
314
+ tools/*.ts one file per tool; the filename is the tool name
315
+ widgets/<name>/ widget.ts for the contract, ui.tsx for the component
316
+ flows/*.ts multi-step conversations, from @waniwani/sdk
317
+ \`\`\`
318
+
319
+ ## Commands
320
+
321
+ \`\`\`bash
322
+ npm run dev # dev server, regenerating on every change
323
+ npm run check # validate the folder without building
324
+ npm run build # production build
325
+ npm run start # run the production build
326
+ \`\`\`
327
+
328
+ \`.waniwani/\` is build output, the way \`.next/\` is. Every command regenerates it
329
+ and it stays out of git.
330
+ `;
331
+ }
332
+
333
+ /**
334
+ * The files a new app gets.
335
+ *
336
+ * `whenPresent` decides what happens to one that is already on disk:
337
+ * `undefined` is a collision that stops the command, `merge` folds the
338
+ * scaffold's contribution into what is there, and `keep` leaves it untouched.
339
+ */
340
+ function scaffold(app, { minimal }) {
341
+ const files = [
342
+ { path: "package.json", contents: packageJson(app), whenPresent: "merge", merge: mergePackageJson },
343
+ { path: ".gitignore", contents: gitignore(), whenPresent: "merge", merge: mergeGitignore },
344
+ { path: ".env.example", contents: envExample(), whenPresent: "keep" },
345
+ { path: "README.md", contents: readme(app), whenPresent: "keep" },
346
+ { path: "waniwani.config.ts", contents: appConfig(app) },
347
+ { path: `tools/${TOOL}.ts`, contents: tool() },
348
+ ];
349
+
350
+ if (!minimal) {
351
+ files.push(
352
+ { path: `widgets/${WIDGET}/widget.ts`, contents: widgetContract() },
353
+ { path: `widgets/${WIDGET}/ui.tsx`, contents: widgetUi() },
354
+ );
355
+ }
356
+
357
+ return files;
358
+ }
359
+
360
+ // -------------------------------------------------------------------- merging
361
+
362
+ /**
363
+ * Add the scripts and dependencies an app needs to a manifest that is already
364
+ * there, and touch nothing else. A key the repo already declares is the repo's
365
+ * decision, including a `dev` script that runs something other than this CLI.
366
+ *
367
+ * @returns the keys added, for the CLI to report
368
+ */
369
+ function mergePackageJson(file, contents) {
370
+ const existing = JSON.parse(readFileSync(file, "utf-8"));
371
+ const generated = JSON.parse(contents);
372
+ const added = [];
373
+
374
+ const fold = (section) => {
375
+ const merged = { ...existing[section] };
376
+ for (const [key, value] of Object.entries(generated[section])) {
377
+ if (merged[key]) continue;
378
+ merged[key] = value;
379
+ added.push(`${section}.${key}`);
380
+ }
381
+ return merged;
382
+ };
383
+
384
+ const next = {
385
+ ...existing,
386
+ // App modules are ESM. A repo that says commonjs is warned about instead of
387
+ // rewritten, since flipping it changes how the rest of that repo loads.
388
+ type: existing.type ?? "module",
389
+ scripts: fold("scripts"),
390
+ dependencies: fold("dependencies"),
391
+ };
392
+ if (!existing.type) added.push("type");
393
+
394
+ if (added.length > 0) {
395
+ writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
396
+ }
397
+ return added;
398
+ }
399
+
400
+ /**
401
+ * Add the lines the app does not ignore yet, leaving every line it wrote alone.
402
+ * A trailing slash is not part of the comparison, so a repo ignoring
403
+ * `node_modules` does not gain `node_modules/` next to it.
404
+ *
405
+ * @returns the lines added, for the CLI to report
406
+ */
407
+ function mergeGitignore(file, contents) {
408
+ const existing = readFileSync(file, "utf-8");
409
+ const bare = (line) => line.trim().replace(/\/$/, "");
410
+ const known = new Set(existing.split("\n").map(bare));
411
+
412
+ const additions = contents
413
+ .split("\n")
414
+ .filter((line) => line.trim() && !line.trim().startsWith("#") && !known.has(bare(line)));
415
+
416
+ if (additions.length === 0) return [];
417
+ const prefix = !existing || existing.endsWith("\n") ? "" : "\n";
418
+ writeFileSync(file, `${existing}${prefix}${additions.join("\n")}\n`);
419
+ return additions;
420
+ }
421
+
422
+ // ------------------------------------------------------------------- the shell
423
+
424
+ function write(file, contents) {
425
+ mkdirSync(dirname(file), { recursive: true });
426
+ writeFileSync(file, contents);
427
+ }
428
+
429
+ /** One question, with the default in parentheses and Enter taking it. */
430
+ async function ask(question, fallback) {
431
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
432
+ try {
433
+ const answer = await rl.question(`${question} ${dim(`(${fallback})`)} `);
434
+ return answer.trim() || fallback;
435
+ } finally {
436
+ rl.close();
437
+ }
438
+ }
439
+
440
+ /**
441
+ * The package manager that invoked this command, which npm, pnpm, yarn and bun
442
+ * all announce in `npm_config_user_agent`. Nothing to detect from lockfiles: a
443
+ * new folder has none, and a `waniwani init` inside an existing repo was still
444
+ * typed with one of the four.
445
+ */
446
+ function packageManager() {
447
+ const agent = process.env.npm_config_user_agent ?? "";
448
+ return ["bun", "pnpm", "yarn", "npm"].find((name) => agent.startsWith(name)) ?? "npm";
449
+ }
450
+
451
+ /** How each manager runs a binary out of node_modules, for the closing lines. */
452
+ const RUNNERS = { npm: "npx", pnpm: "pnpm", yarn: "yarn", bun: "bunx" };
453
+
454
+ function install(root, manager) {
455
+ console.log(`\n${dim(`installing with ${manager}…`)}`);
456
+ return new Promise((resolvePromise) => {
457
+ const child = spawn(manager, ["install"], {
458
+ cwd: root,
459
+ stdio: "inherit",
460
+ // npm and yarn are .cmd shims on Windows, which execvp cannot run.
461
+ shell: process.platform === "win32",
462
+ });
463
+ child.on("close", (code) => resolvePromise(code === 0));
464
+ child.on("error", () => resolvePromise(false));
465
+ });
466
+ }
467
+
468
+ /**
469
+ * Scaffold an app folder, install its dependencies, and say what to run.
470
+ *
471
+ * @param appRoot the directory to scaffold, created if it does not exist
472
+ * @param flags parsed CLI flags
473
+ * @param options.targeted a directory was named on the command line
474
+ * @returns a process exit code
475
+ */
476
+ export async function init(appRoot, flags, { targeted = true } = {}) {
477
+ const interactive = process.stdin.isTTY && !flags.yes && !flags.name;
478
+ const suggested = slugify(basename(appRoot));
479
+
480
+ // One question, and both fields come out of the answer: `Acme Shop` gives the
481
+ // server the name `acme-shop` and keeps `Acme Shop` as the title. An answer
482
+ // that is already a slug gets a title with a capital on the front.
483
+ const answer = flags.name ?? (interactive ? await ask("App name", suggested) : suggested);
484
+ const name = slugify(answer);
485
+ const typed = cleanTitle(answer);
486
+ const app = { name, title: typed && typed !== name ? typed : titleize(name) };
487
+
488
+ // A name typed at the prompt with no directory to put it in names the
489
+ // directory as well: `oney` in ~/Projects means ~/Projects/oney, which is how
490
+ // create-next-app's one question reads. Taking the offered default leaves
491
+ // everything where it is, since that default is the current folder's own name,
492
+ // and `waniwani init .` names the current folder outright.
493
+ const root = !targeted && interactive && name !== suggested ? join(appRoot, name) : appRoot;
494
+
495
+ const files = scaffold(app, { minimal: Boolean(flags.minimal) });
496
+
497
+ // Nothing is written until every collision is known, so a refusal leaves the
498
+ // directory exactly as it was.
499
+ const clashes = files.filter((file) => !file.whenPresent && existsSync(join(root, file.path)));
500
+ if (clashes.length > 0 && !flags.force) {
501
+ console.error(`\n${red("✗")} ${bold("already an app folder here")}\n`);
502
+ for (const file of clashes) {
503
+ console.error(` ${file.path}`);
504
+ }
505
+ console.error(`\n${dim("pass --force to overwrite, or init into a new directory")}`);
506
+ return 1;
507
+ }
508
+
509
+ mkdirSync(root, { recursive: true });
510
+
511
+ const actions = [];
512
+ for (const file of files) {
513
+ const target = join(root, file.path);
514
+
515
+ if (!existsSync(target)) {
516
+ write(target, file.contents);
517
+ actions.push([green("+"), file.path, null]);
518
+ continue;
519
+ }
520
+ if (file.whenPresent === "keep") {
521
+ actions.push([dim("·"), file.path, "yours, left alone"]);
522
+ continue;
523
+ }
524
+ if (file.whenPresent === "merge") {
525
+ const changed = file.merge(target, file.contents);
526
+ actions.push([
527
+ changed.length > 0 ? yellow("~") : dim("·"),
528
+ file.path,
529
+ changed.length > 0 ? `+ ${changed.join(", ")}` : "nothing to add",
530
+ ]);
531
+ continue;
532
+ }
533
+ // A collision the caller chose to overwrite.
534
+ write(target, file.contents);
535
+ actions.push([yellow("~"), file.path, "overwritten"]);
536
+ }
537
+
538
+ console.log(`\n${green("✓")} ${bold(app.name)} ${dim(`→ ${root}`)}\n`);
539
+ for (const [marker, path, note] of actions) {
540
+ console.log(` ${marker} ${path}${note ? ` ${dim(note)}` : ""}`);
541
+ }
542
+
543
+ // The merge leaves a script the repo already had alone, so the way into the dev
544
+ // loop is whatever survived that: `npm run dev` when it is ours, the CLI by
545
+ // name when the repo's own `dev` runs something else.
546
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
547
+ const ours = manifest.scripts?.dev === "waniwani dev";
548
+
549
+ if (manifest.type !== "module") {
550
+ console.log(
551
+ `\n${yellow("!")} ${bold("package.json")} says ${bold(`"type": "${manifest.type}"`)}`,
552
+ );
553
+ console.log(` ${dim('app modules are ESM: set it to "module" or the build cannot load them')}`);
554
+ }
555
+
556
+ const manager = packageManager();
557
+ // A scaffold with no node_modules still checks out and still reads, so a
558
+ // failed install is reported and the folder is kept.
559
+ const installed = flags.install === false ? null : await install(root, manager);
560
+
561
+ console.log(`\n${bold("From here")}`);
562
+ // `init apps/store` is scaffolded two levels down, so the path is the one to
563
+ // print rather than the directory's own name.
564
+ const from = relative(process.cwd(), root);
565
+ if (from) {
566
+ console.log(` cd ${from}`);
567
+ }
568
+ if (installed === false) {
569
+ console.log(` ${yellow(`${manager} install`)} ${dim("(the first attempt failed)")}`);
570
+ } else if (installed === null) {
571
+ console.log(` ${manager} install`);
572
+ }
573
+ console.log(` ${ours ? `${manager} run dev` : `${RUNNERS[manager]} waniwani dev`}`);
574
+
575
+ console.log(`\n${bold("Then")}`);
576
+ console.log(` ${dim("·")} edit ${bold(`tools/${TOOL}.ts`)} to answer with your own data`);
577
+ if (!flags.minimal) {
578
+ console.log(` ${dim("·")} edit ${bold(`widgets/${WIDGET}/ui.tsx`)} for how it looks on screen`);
579
+ }
580
+ console.log(` ${dim("·")} add ${bold("flows/<name>.ts")} for a multi-step conversation`);
581
+ return 0;
582
+ }
package/cli/log.mjs CHANGED
@@ -150,7 +150,6 @@ export function printReport(app, report) {
150
150
  [app.widgets.length, "widget"],
151
151
  [app.tools.length, "tool"],
152
152
  [app.flows.length, "flow"],
153
- [app.docs.length, "doc"],
154
153
  ]
155
154
  .filter(([count]) => count > 0)
156
155
  .map(([count, label]) => `${count} ${label}${count === 1 ? "" : "s"}`);
@@ -166,10 +165,6 @@ export function printReport(app, report) {
166
165
  for (const flow of app.flows) {
167
166
  console.log(` ${dim("flow ")} ${flow.name}`);
168
167
  }
169
- if (app.docs.length > 0) {
170
- console.log(` ${dim("tool ")} search_docs ${dim(`(${app.docs.length} pages)`)}`);
171
- }
172
-
173
168
  if (report.warnings.length > 0) {
174
169
  console.log("");
175
170
  printGroup(report.warnings, "└", yellow);
package/cli/scan.mjs CHANGED
@@ -5,7 +5,6 @@
5
5
  * tools/<name>.ts
6
6
  * widgets/<name>/{widget.ts,ui.tsx}
7
7
  * flows/<name>.ts
8
- * docs/<slug>.md
9
8
  *
10
9
  * There is no CSS in that list. Styling is Tailwind, from the distribution
11
10
  * template's `src/index.css` — its `@theme` tokens and its `dark` variant — and
@@ -13,7 +12,7 @@
13
12
  * collected only so the build check can tell an author they are dead.
14
13
  */
15
14
 
16
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
17
16
  import { basename, extname, join } from "node:path";
18
17
 
19
18
  const CODE_EXT = new Set([".ts", ".tsx", ".mts"]);
@@ -38,12 +37,6 @@ function stripExt(path) {
38
37
  return basename(path, extname(path));
39
38
  }
40
39
 
41
- /** `# Title` on the first heading line, falling back to the slug. */
42
- function docTitle(body, slug) {
43
- const heading = body.split("\n").find((line) => line.startsWith("# "));
44
- return heading ? heading.slice(2).trim() : slug;
45
- }
46
-
47
40
  export function scanApp(root) {
48
41
  const configFile = [join(root, "waniwani.config.ts"), join(root, "waniwani.config.js")].find(
49
42
  existsSync,
@@ -64,14 +57,6 @@ export function scanApp(root) {
64
57
  .filter((file) => CODE_EXT.has(extname(file)))
65
58
  .map((file) => ({ name: stripExt(file), file }));
66
59
 
67
- const docs = listFiles(join(root, "docs"))
68
- .filter((file) => extname(file) === ".md")
69
- .map((file) => {
70
- const slug = stripExt(file);
71
- const body = readFileSync(file, "utf-8").trim();
72
- return { slug, file, title: docTitle(body, slug), body };
73
- });
74
-
75
60
  // The two paths an author is most likely to expect the kit to pick up. It
76
61
  // imports neither, so a file at either one is styling that never reaches the
77
62
  // browser — the kind of silent no-op the build check exists to name.
@@ -80,5 +65,5 @@ export function scanApp(root) {
80
65
  ...widgets.map((widget) => join(widget.dir, "styles.css")),
81
66
  ].filter(existsSync);
82
67
 
83
- return { root, configFile, tools, widgets, flows, docs, strayStyles };
68
+ return { root, configFile, tools, widgets, flows, strayStyles };
84
69
  }
package/cli/validate.mjs CHANGED
@@ -47,11 +47,11 @@ function checkStructure(app, report) {
47
47
  );
48
48
  }
49
49
 
50
- if (app.tools.length + app.widgets.length + app.flows.length + app.docs.length === 0) {
50
+ if (app.tools.length + app.widgets.length + app.flows.length === 0) {
51
51
  report.error(
52
52
  ".",
53
53
  "this app exposes nothing",
54
- "add a tool (tools/<name>.ts), a widget (widgets/<name>/), a flow (flows/<name>.ts), or docs (docs/<slug>.md)",
54
+ "add a tool (tools/<name>.ts), a widget (widgets/<name>/), or a flow (flows/<name>.ts)",
55
55
  );
56
56
  }
57
57
 
package/dist/index.d.ts CHANGED
@@ -12,7 +12,6 @@
12
12
  * widgets/<name>/widget.ts export default defineWidget({ ... })
13
13
  * widgets/<name>/ui.tsx export default function Component() { ... }
14
14
  * flows/<name>.ts export default createFlow({ ... }).compile()
15
- * docs/<slug>.md searchable knowledge
16
15
  */
17
16
  import type { z } from "zod";
18
17
  /** A Zod object shape — `{ name: z.string() }`, not `z.object({ ... })`. */
@@ -104,10 +103,4 @@ export type WidgetDefinition<S extends Shape = Shape> = {
104
103
  load?: (input: Infer<S>) => Infer<S> | Promise<Infer<S>>;
105
104
  };
106
105
  export declare function defineWidget<S extends Shape>(def: WidgetDefinition<S>): WidgetDefinition<S>;
107
- /** A `docs/<slug>.md` file, parsed at build time and inlined into the bundle. */
108
- export type DocEntry = {
109
- slug: string;
110
- title: string;
111
- body: string;
112
- };
113
106
  //# sourceMappingURL=index.d.ts.map