@waniwani/kit 0.1.1 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @waniwani/kit
2
2
 
3
- **Build an MCP app as a folder.** You write tools, widgets, flows and docs into
3
+ **Build an MCP app as a folder.** You write tools, widgets and flows into
4
4
  a directory, and one CLI turns that directory into a deployable MCP server. Your
5
5
  repo holds none of the plumbing: server bootstrap, transport wiring, build
6
6
  configuration.
@@ -15,8 +15,7 @@ oney/ # what you write
15
15
  ├── waniwani.config.ts
16
16
  ├── tools/check-eligibility.ts
17
17
  ├── widgets/select-plan/{widget.ts,ui.tsx}
18
- ├── flows/split-payment.ts
19
- └── docs/*.md
18
+ └── flows/split-payment.ts
20
19
 
21
20
  waniwani build # → .waniwani/, an ordinary npm project
22
21
  ```
@@ -82,9 +81,25 @@ registers them come from the kit.
82
81
 
83
82
  ## Quickstart
84
83
 
85
- There is no `waniwani init` yet, so the first files go in by hand.
86
- [examples/oney](https://github.com/WaniWani-AI/kit/blob/main/examples/oney) is this same app finished, if you would rather
87
- read it than type it.
84
+ ```bash
85
+ npx @waniwani/kit init oney
86
+ cd oney && npm run dev
87
+ ```
88
+
89
+ `init` writes a folder that already answers: an app config, one tool, and the
90
+ widget that displays what the tool returned. It installs, and the dev
91
+ server is one command away. `--minimal` scaffolds the config and the tool alone,
92
+ `--name` sets the MCP server name, and running it inside an existing repo merges
93
+ into that repo's `package.json` and `.gitignore` instead of replacing them.
94
+
95
+ Where the app lands follows the argument. `init oney` creates `oney/`, `init .`
96
+ uses the current folder, and a bare `init` asks for a name and reads the answer
97
+ as both: a name of its own creates `./<name>/`, while the offered default, your
98
+ current folder's name, scaffolds in place.
99
+
100
+ The rest of this section is what those files hold, written out by hand.
101
+ [examples/oney](https://github.com/WaniWani-AI/kit/blob/main/examples/oney) is the same app finished, if you would rather read
102
+ it than type it.
88
103
 
89
104
  ```bash
90
105
  mkdir oney && cd oney
@@ -235,9 +250,8 @@ oney/
235
250
  │ └── ui.tsx export default function Component()
236
251
  ├── flows/
237
252
  │ └── split-payment.ts export default createFlow(...).compile() ← SDK
238
- ├── docs/
239
- ├── fees.md becomes the `search_docs` tool
240
- │ └── eligibility.md
253
+ ├── api/
254
+ └── cal/slots.ts export default defineEndpoint({ ..., handler })
241
255
  └── lib/ anything else is just modules
242
256
  ```
243
257
 
@@ -251,7 +265,7 @@ unwired.
251
265
  | `tools/<name>.ts` | one MCP tool | `.ts`, `.tsx` and `.mts` are picked up |
252
266
  | `widgets/<name>/` | one MCP tool plus a `ui://` resource | needs `widget.ts` and `ui.tsx` |
253
267
  | `flows/<name>.ts` | one MCP tool, registered from the SDK unchanged | whatever `.compile()` returns |
254
- | `docs/*.md` | a single `search_docs` tool over all of them | the first `# heading` becomes the title |
268
+ | `api/<path>.ts` | one HTTP endpoint at `/api/<path>` | for the browser, invisible to the model |
255
269
  | anything else | plain modules | the CLI leaves it alone |
256
270
 
257
271
  The app folder imports `@waniwani/kit`, plus `@waniwani/sdk` when it uses flows,
@@ -316,6 +330,70 @@ export default createFlow({ id: "split_payment", title, description, state })
316
330
  `showWidget({ tool: "select-plan" })` names a widget by its folder name, and the
317
331
  build check verifies that the folder exists.
318
332
 
333
+ ### The api/ folder is for the browser
334
+
335
+ A widget runs in an iframe on another origin, and it can call its own server
336
+ without going through the model at all. Booking a slot, loading a calendar,
337
+ receiving a webhook: `api/` is where those live. The path comes from the file's
338
+ position, the folder name included, so there is nothing to keep in step with the
339
+ `fetch()` on the other side:
340
+
341
+ ```
342
+ api/cal/slots.ts → /api/cal/slots
343
+ api/webhooks/stripe.ts → /api/webhooks/stripe
344
+ api/cal/index.ts → /api/cal
345
+ ```
346
+
347
+ ```ts
348
+ // api/cal/slots.ts
349
+ import { defineEndpoint } from "@waniwani/kit";
350
+ import { fetchCalSlots } from "../../lib/cal.js";
351
+
352
+ export default defineEndpoint({
353
+ method: "post",
354
+ handler: async (req, res) => {
355
+ const { timeZone } = req.body;
356
+ res.json({ slots: await fetchCalSlots(regionFor(timeZone)) });
357
+ },
358
+ });
359
+ ```
360
+
361
+ The widget reaches it at the origin the host hands the view, which is the dev
362
+ port locally and the deployed origin inside ChatGPT or Claude:
363
+
364
+ ```tsx
365
+ const apiUrl = (path: string) => `${window.skybridge?.serverUrl ?? ""}${path}`;
366
+ const response = await fetch(apiUrl("/api/cal/slots"), { method: "POST", body });
367
+ ```
368
+
369
+ Four things arrive with every endpoint, so no app writes them:
370
+
371
+ | | what the runtime does |
372
+ |---|---|
373
+ | **CORS** | on by default, preflight included, advertising the methods `method` declares and no others |
374
+ | **JSON body** | `express.json()`, because the framework installs no parser of its own and `req.body` would be `undefined` |
375
+ | **method guard** | anything outside `method` gets a 405 and an `Allow` header, instead of reaching a handler written for a POST |
376
+ | **errors** | a handler that throws answers JSON with the message, logged as `[waniwani] endpoint "/api/..." failed`, so a `fetch()` waiting on JSON never receives an HTML error page |
377
+
378
+ `cors: false` and `json: false` opt out of the first two. Leaving `method` off
379
+ accepts every method.
380
+
381
+ **Reach for a tool instead when the model is the caller.** An endpoint appears in
382
+ no `tools/list`, costs no turn and no tokens, and the model cannot see that it
383
+ happened. That is what suits a calendar the widget paints for itself, and what
384
+ rules it out for anything the model has to reason about or quote back.
385
+
386
+ Endpoints share the process with `/mcp`, so `lib/` is one set of modules for
387
+ both, and the build check prints what it mounted:
388
+
389
+ ```
390
+ ✓ Build check passed — 1 widget, 1 flow, 2 endpoints
391
+ widget show-book-call
392
+ flow demo-qualification
393
+ api /api/cal/book
394
+ api /api/cal/slots
395
+ ```
396
+
319
397
  ### Styling is Tailwind, and only Tailwind
320
398
 
321
399
  A widget styles itself with utility classes in its `ui.tsx`. There is no
@@ -392,15 +470,16 @@ letting it sit there doing nothing:
392
470
  ## Commands
393
471
 
394
472
  ```bash
473
+ waniwani init [dir] # scaffold an app folder, install, ready to dev
395
474
  waniwani check # validate the folder
396
475
  waniwani dev # generate + dev server + regenerate on change
397
476
  waniwani build # generate + production build
398
477
  waniwani start # run the production build
399
- waniwani deploy # generate + vercel deploy
400
478
  waniwani eject [--out dir] # hand the plumbing over and step out
401
479
  ```
402
480
 
403
- Every one of them runs the same four stages before doing its own work.
481
+ `init` writes files and stops there. Every other command runs the same four
482
+ stages before doing its own work.
404
483
 
405
484
  ```mermaid
406
485
  flowchart LR
@@ -409,7 +488,7 @@ flowchart LR
409
488
  tools["tools/*.ts"]
410
489
  widgets["widgets/&lt;name&gt;/<br/>widget.ts + ui.tsx"]
411
490
  flows["flows/*.ts"]
412
- docs["docs/*.md"]
491
+ api["api/**/*.ts"]
413
492
  end
414
493
 
415
494
  subgraph tpl["WaniWani-AI/mcp-distribution-template (public, separate repo)"]
@@ -433,7 +512,7 @@ flowchart LR
433
512
  app --> scan --> check --> gen --> out
434
513
  runtime -.imported by.-> server
435
514
  raw -.fetched at a pinned SHA, copied byte for byte.-> deployfiles
436
- out --> deploy["dev · build · start<br/>vercel deploy"]
515
+ out --> deploy["dev · build · start"]
437
516
  app -.waniwani eject.-> ejected["a plain repo<br/><i>no CLI, no @waniwani/kit</i>"]
438
517
  ```
439
518
 
@@ -450,6 +529,28 @@ flowchart LR
450
529
  project carrying a `vercel.json`, which is what lets `vercel deploy` inside it
451
530
  work with no special support.
452
531
 
532
+ ### Secrets live in the app's .env
533
+
534
+ `.env` and `.env.local` sit next to `waniwani.config.ts`, and every command reads
535
+ them before it runs anything. A variable already exported in the shell or set by
536
+ CI wins over both files, and a hosted deploy sets its variables on the platform
537
+ and reads no file at all.
538
+
539
+ Loading them this early is what lets a module build its client at import time:
540
+
541
+ ```ts
542
+ // lib/waniwani.ts
543
+ export const wani = waniwani({ apiKey: process.env.WANIWANI_API_KEY });
544
+ ```
545
+
546
+ The generated project runs from `.waniwani/`, one level below the file, and a
547
+ module's imports are evaluated before any line of the module that pulled it in,
548
+ so neither `dotenv/config` nor a load inside generated code arrives in time.
549
+ `waniwani check` reads the same files for the same reason: it imports every
550
+ server-safe module for real, and a flow whose store comes from
551
+ `WANIWANI_API_KEY` would otherwise fail its own build check over a variable
552
+ sitting in the file next to it.
553
+
453
554
  ### What the build check catches
454
555
 
455
556
  Errors that would otherwise surface as a 500 at request time, or as a widget
@@ -513,9 +614,9 @@ under `src/app/`:
513
614
 
514
615
  ```
515
616
  oney/
516
- ├── src/app/{tools,widgets,flows,docs,lib}/ your code, moved
617
+ ├── src/app/{tools,widgets,flows,lib}/ your code, moved
517
618
  ├── src/_runtime/ the runtime, vendored as source
518
- ├── src/{server,waniwani,docs}.ts entry, registration, inlined docs
619
+ ├── src/{server,waniwani}.ts entry and registration
519
620
  ├── src/views/<widget>.tsx view entries
520
621
  ├── vite.config.ts vercel.json alpic.json bundling and deploy
521
622
  ├── Dockerfile .dockerignore container deploy
@@ -545,8 +646,6 @@ What an ejected repo gives up is the generator, and with it:
545
646
  - **the build check**, so `showWidget("typo")` becomes a runtime failure again
546
647
  - **name-from-filesystem**, so adding a widget means editing `src/waniwani.ts`
547
648
  and adding an entry under `src/views/`
548
- - **docs auto-scan**, since `src/docs.ts` is a snapshot and new `docs/*.md` need
549
- hand-wiring
550
649
  - **runtime fixes**, since `src/_runtime/` is a fork from the moment it lands
551
650
 
552
651
  ## Status
@@ -555,12 +654,12 @@ What an ejected repo gives up is the generator, and with it:
555
654
 
556
655
  - **`@waniwani/cli` owns the `waniwani` bin on npm**, so installing both
557
656
  collides.
558
- - **No `waniwani init`**, so a new app folder starts by hand.
559
- - **`waniwani deploy` is wired but unrun** against a real Vercel account.
657
+ - **`waniwani init` scaffolds one shape of app**, a tool with the widget that
658
+ displays it. A flow is not among the files it writes.
659
+ - **Deploying is manual.** `.waniwani/` carries a `vercel.json`, so
660
+ `vercel deploy` inside it works, but no command wraps that.
560
661
  - **`useWidget` does not track yet.** Emitting `widget_render` and click events
561
662
  through `useWaniwani` automatically is the next step.
562
- - **Docs search is a term-match** rather than the hosted KB. Swapping it for
563
- `wani.kb.search` when `WANIWANI_API_KEY` is set is a runtime change only.
564
663
 
565
664
  Template pinning, the CI contract, publishing requirements and the rest of the
566
665
  gap list are in [INTERNALS.md](https://github.com/WaniWani-AI/kit/blob/main/INTERNALS.md).
package/cli/codegen.mjs CHANGED
@@ -201,7 +201,7 @@ const SCRIPT_ADDITIONS = {
201
201
  */
202
202
  const SCRIPT_REMOVALS = {
203
203
  "kb:ingest": {
204
- why: "ingests knowledge-base/, which is the example's; an app's docs are docs/*.md, inlined at build",
204
+ why: "ingests knowledge-base/, which is the example's; an app has no such folder",
205
205
  },
206
206
  };
207
207
 
@@ -247,9 +247,7 @@ const NOT_SOURCE = new Set([
247
247
  ".skybridge",
248
248
  ".vercel",
249
249
  // The repo's own, not the app's. An in-place eject moves what it copies, and
250
- // a README that reappears under `src/app/` is a bad surprise. Docs the app
251
- // actually serves live in `docs/*.md` and are inlined separately, so nothing
252
- // is lost by skipping these at every level.
250
+ // a README that reappears under `src/app/` is a bad surprise.
253
251
  "README.md",
254
252
  "LICENSE",
255
253
  // Lockfiles describe the repo's install, and the generated package.json is
@@ -268,7 +266,7 @@ const NOT_SOURCE = new Set([
268
266
  * tools in it, and reads `src/waniwani.ts` — the one file this generates into
269
267
  * the template's tree.
270
268
  */
271
- const GENERATED = ["src/waniwani.ts", "src/docs.ts", "tsconfig.json", ".template.json"];
269
+ const GENERATED = ["src/waniwani.ts", "tsconfig.json", ".template.json"];
272
270
 
273
271
  /** `select-plan` -> `selectPlan`, for generated identifiers. */
274
272
  function camel(name) {
@@ -592,20 +590,22 @@ function templateStyleDomains(template) {
592
590
 
593
591
  // ------------------------------------------------------------ generated files
594
592
 
595
- function generateServerApp(app, layout, { runtime, styleDomains }) {
593
+ function generateServerApp(app, layout, { runtime, styleDomains, version }) {
596
594
  const from = appFrom(layout);
597
595
 
598
596
  const imports = [
599
597
  `import { config as loadEnv } from "dotenv";`,
600
598
  `import type { McpServer } from "skybridge/server";`,
601
599
  `import { registerApp as register } from "${runtime.server}";`,
602
- app.docs.length > 0 ? `import { docs } from "./docs.js";` : null,
603
600
  `import config from "${from}/waniwani.config.js";`,
604
601
  ...app.tools.map((t) => `import tool_${camel(t.name)} from "${from}/tools/${t.name}.js";`),
605
602
  ...app.widgets.map(
606
603
  (w) => `import widget_${camel(w.name)} from "${from}/widgets/${w.name}/widget.js";`,
607
604
  ),
608
605
  ...app.flows.map((f) => `import flow_${camel(f.name)} from "${from}/flows/${f.name}.js";`),
606
+ ...app.endpoints.map(
607
+ (e) => `import endpoint_${camel(e.segments.join("-"))} from "${from}/api/${e.segments.join("/")}.js";`,
608
+ ),
609
609
  ].filter(Boolean);
610
610
 
611
611
  const list = (items) => (items.length === 0 ? "[]" : `[\n\t\t${items.join(",\n\t\t")},\n\t]`);
@@ -618,10 +618,12 @@ ${imports.join("\n")}
618
618
  // on whether this is a generated build or an ejected project.
619
619
  loadEnv({ path: ["../.env", ".env"], quiet: true });
620
620
 
621
+ // The version the app's package.json carries is the fallback, so a bumped
622
+ // release shows up in the connector UI without a second edit here.
621
623
  export const app = {
622
624
  name: config.name,
623
625
  title: config.title,
624
- version: config.version ?? "0.0.0",
626
+ version: config.version ?? ${JSON.stringify(version ?? "0.0.0")},
625
627
  instructions: config.instructions,
626
628
  };
627
629
 
@@ -630,7 +632,13 @@ export async function registerApp(server: McpServer): Promise<void> {
630
632
  tools: ${list(app.tools.map((t) => `{ name: "${t.name}", def: tool_${camel(t.name)} }`))},
631
633
  widgets: ${list(app.widgets.map((w) => `{ name: "${w.name}", def: widget_${camel(w.name)} }`))},
632
634
  flows: ${list(app.flows.map((f) => `flow_${camel(f.name)}`))},
633
- docs: ${app.docs.length > 0 ? "docs" : "[]"},
635
+ // Served by the same Express app as /mcp, at the path each file's position
636
+ // produced. For the browser — a widget's fetch — not for the model.
637
+ endpoints: ${list(
638
+ app.endpoints.map(
639
+ (e) => `{ path: "${e.path}", def: endpoint_${camel(e.segments.join("-"))} }`,
640
+ ),
641
+ )},
634
642
  // Read off the template's ${STYLE_ENTRY}, which every view imports.
635
643
  styleDomains: ${list(styleDomains.map((origin) => `"${origin}"`))},
636
644
  });
@@ -638,22 +646,6 @@ export async function registerApp(server: McpServer): Promise<void> {
638
646
  `;
639
647
  }
640
648
 
641
- /**
642
- * Docs are inlined into a module rather than read from disk, so they survive a
643
- * serverless bundle with no filesystem.
644
- */
645
- function generateDocs(app, { runtime }) {
646
- return `// Generated from docs/*.md.
647
- import type { DocEntry } from "${runtime.index}";
648
-
649
- export const docs: DocEntry[] = ${JSON.stringify(
650
- app.docs.map((doc) => ({ slug: doc.slug, title: doc.title, body: doc.body })),
651
- null,
652
- 2,
653
- )};
654
- `;
655
- }
656
-
657
649
  function generateWidgetShim(widget, layout) {
658
650
  // From `src/views/` up to `src/`, then out to the app's source.
659
651
  const from = `../${basename(layout.appDir)}`;
@@ -740,7 +732,6 @@ function generateBiome(template, layout) {
740
732
  // Generated and vendored code is not the app author's to fix.
741
733
  `!${layout.runtimeDir}/**`,
742
734
  "!src/server.ts",
743
- "!src/docs.ts",
744
735
  "!src/views/**",
745
736
  ...negative,
746
737
  ],
@@ -861,7 +852,7 @@ function assertSeam(template) {
861
852
 
862
853
  throw new Error(
863
854
  `the template at ${template.source} never calls ${SEAM.symbol}(), so this app's\n` +
864
- ` tools, widgets, flows, and docs would be built and then silently dropped.\n\n` +
855
+ ` tools, widgets and flows would be built and then silently dropped.\n\n` +
865
856
  ` Add to its ${SEAM.file}:\n\n` +
866
857
  ` import { app, registerApp } from "./waniwani.js";\n\n` +
867
858
  ` const server = new McpServer(\n` +
@@ -999,14 +990,19 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
999
990
  }
1000
991
  }
1001
992
 
993
+ const appPackageJsonPath = join(app.root, "package.json");
994
+ const appPackageJson = existsSync(appPackageJsonPath)
995
+ ? JSON.parse(readFileSync(appPackageJsonPath, "utf-8"))
996
+ : undefined;
997
+
1002
998
  emit(
1003
999
  "src/waniwani.ts",
1004
- generateServerApp(app, layout, { runtime, styleDomains: templateStyleDomains(template) }),
1000
+ generateServerApp(app, layout, {
1001
+ runtime,
1002
+ styleDomains: templateStyleDomains(template),
1003
+ version: appPackageJson?.version,
1004
+ }),
1005
1005
  );
1006
- if (app.docs.length > 0) {
1007
- emit("src/docs.ts", generateDocs(app, { runtime }));
1008
- }
1009
-
1010
1006
  // `src/views/` is shared: the template's own views sit alongside the app's,
1011
1007
  // so it cannot be wiped. Only the entries a previous build wrote are
1012
1008
  // removed, which is what clears a widget the app has since deleted.
@@ -1019,11 +1015,6 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
1019
1015
  emit(`src/views/${widget.name}.tsx`, generateWidgetShim(widget, layout));
1020
1016
  }
1021
1017
 
1022
- const appPackageJsonPath = join(app.root, "package.json");
1023
- const appPackageJson = existsSync(appPackageJsonPath)
1024
- ? JSON.parse(readFileSync(appPackageJsonPath, "utf-8"))
1025
- : undefined;
1026
-
1027
1018
  const { packageJson, overrides } = generatePackageJson(app, appPackageJson, template, layout);
1028
1019
 
1029
1020
  emit("tsconfig.json", `${JSON.stringify(generateTsconfig(template, layout), null, 2)}\n`);
@@ -1046,9 +1037,14 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
1046
1037
  sha: template.sha,
1047
1038
  local: template.local,
1048
1039
  manifest: manifest ? MANIFEST_FILE : undefined,
1049
- // What survived to the end: the generated files land on top of
1050
- // the copy, so the raw list overstates it.
1051
- files: fromTemplate.filter((file) => existsSync(join(root, file))),
1040
+ // What survived to the end, copied and generated alike. The
1041
+ // copy is the raw list minus whatever a generated file replaced,
1042
+ // and the generated half is here so that a build which stops
1043
+ // emitting one — `src/docs.ts` when docs left the framework —
1044
+ // cleans up the copy the previous build left behind.
1045
+ files: [...new Set([...fromTemplate, ...GENERATED])].filter((file) =>
1046
+ existsSync(join(root, file)),
1047
+ ),
1052
1048
  // Tracked separately because `src/views/` is shared with the
1053
1049
  // template — the next build needs to know which entries were
1054
1050
  // ours before it removes any.
package/cli/env.mjs ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The app's `.env`, loaded into this CLI's own environment.
3
+ *
4
+ * Two problems make this the only place it can happen.
5
+ *
6
+ * The framework runs from the generated project, so `process.cwd()` is
7
+ * `.waniwani/` and a bare `dotenv/config` looks for `.waniwani/.env` — a file
8
+ * nobody wrote. The app's `.env` sits one level up, next to `waniwani.config.ts`.
9
+ *
10
+ * Loading it from generated code inside that project is too late anyway. ESM
11
+ * evaluates a module's imports before its body, so by the time any line of
12
+ * `src/waniwani.ts` runs, every app module it imports has already been
13
+ * evaluated — and a module that builds an API client at import time has already
14
+ * read the empty environment and captured it. `createFlow(...).compile()` is the
15
+ * loud version of this: it throws at import time when `WANIWANI_API_KEY` is
16
+ * absent.
17
+ *
18
+ * Loading here, before anything is spawned, puts the variables in the
19
+ * environment every child process inherits, whatever order its modules load in.
20
+ * Hosted deploys set their variables on the platform and never reach this path.
21
+ */
22
+
23
+ import { join } from "node:path";
24
+ import dotenv from "dotenv";
25
+
26
+ const loaded = new Set();
27
+
28
+ /**
29
+ * `.env.local` before `.env`: the first file to define a variable wins, and
30
+ * dotenv never overwrites one already in the environment, so a value exported in
31
+ * the shell or set by CI outranks both files.
32
+ */
33
+ export function loadAppEnv(root) {
34
+ if (loaded.has(root)) return;
35
+ loaded.add(root);
36
+ dotenv.config({ path: [join(root, ".env.local"), join(root, ".env")], quiet: true });
37
+ }
package/cli/framework.mjs CHANGED
@@ -91,7 +91,6 @@ function reword(line) {
91
91
  *
92
92
  * The framework's own tunnel is one of those lines. No command here asks for it,
93
93
  * so what arrives is its offer of one, and the emoji it carries drops the line.
94
- * A public hostname comes from `waniwani tunnel` instead (see ./tunnel.mjs).
95
94
  *
96
95
  * Each pattern leads with `\W*` to absorb whatever emoji prefixes the line and
97
96
  * ends at `$`, so a rule reads the framework's whole line and can't fire on an