@waniwani/kit 0.1.8 → 0.1.10

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
@@ -20,9 +20,6 @@ oney/ # what you write
20
20
  waniwani build # → .waniwani/, an ordinary npm project
21
21
  ```
22
22
 
23
- The machinery underneath is in [INTERNALS.md](https://github.com/WaniWani-AI/kit/blob/main/INTERNALS.md): template
24
- resolution, dependency overrides, CLI output, publishing, the full gap list.
25
-
26
23
  ## The three packages
27
24
 
28
25
  Three packages ship under the `@waniwani` scope. The pair people mix up is the
@@ -88,9 +85,28 @@ cd oney && npm run dev
88
85
 
89
86
  `init` writes a folder that already answers: an app config, one tool, and the
90
87
  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.
88
+ server is one command away. Running it inside an existing repo merges into that
89
+ repo's `package.json` and `.gitignore` instead of replacing them.
90
+
91
+ In a terminal it asks three questions, arrow keys and Enter:
92
+
93
+ ```
94
+ ┌ A new MCP app
95
+ │
96
+ ◇ App name
97
+ │ oney
98
+ │
99
+ ◆ What should it come with?
100
+ │ ● A tool and a widget (the hand-off between them, wired up)
101
+ │ ○ Just a tool
102
+ │ ↑/↓ to navigate • Enter: confirm
103
+ └
104
+ ```
105
+
106
+ Every question has a flag that answers it ahead of time (`--name`, `--minimal`,
107
+ `--host`), and a question whose answer is already in hand is not asked. `--yes`
108
+ takes every default and asks nothing, which is also what happens where there is
109
+ no terminal to ask in: a pipe, or CI.
94
110
 
95
111
  Where the app lands follows the argument. `init oney` creates `oney/`, `init .`
96
112
  uses the current folder, and a bare `init` asks for a name and reads the answer
@@ -330,6 +346,22 @@ export default createFlow({ id: "split_payment", title, description, state })
330
346
  `showWidget({ tool: "select-plan" })` names a widget by its folder name, and the
331
347
  build check verifies that the folder exists.
332
348
 
349
+ ### Which SDK version an app gets
350
+
351
+ `@waniwani/sdk` is a peer rather than a pin. The app's own `package.json` names
352
+ the version, and this package states only the floor underneath it, so an app
353
+ that upgrades keeps that choice through every build. Nothing here rewrites the
354
+ range.
355
+
356
+ `waniwani init` writes the newest published SDK it can reach, capped with a
357
+ caret, and falls back to the declared floor when npm is unreachable. Set
358
+ `WANIWANI_OFFLINE=1` to skip the lookup entirely.
359
+
360
+ The SDK is 0.x, where a caret stops at the next minor. `^0.20.0` picks up
361
+ 0.20.1 on the next install and never crosses to 0.21 on its own. When a newer
362
+ minor is published, `waniwani check` says so and names the one-line edit; taking
363
+ it is the app's call, since under 0.x a minor is a breaking change.
364
+
333
365
  ### The api/ folder is for the browser
334
366
 
335
367
  A widget runs in an iframe on another origin, and it can call its own server
@@ -533,11 +565,13 @@ flowchart LR
533
565
  config file the repo carries:
534
566
 
535
567
  ```
536
- Where will this deploy?
537
- 1 Vercel — git push, or `vercel deploy --prebuilt`
538
- 2 Alpic — alpic.json comes from the build
539
- 3 Docker or self-hosted — Dockerfile comes from the build
540
- 4 Not yet — nothing written, add it later
568
+ ◆ Where will this deploy?
569
+ │ ● Vercel (git push, or `vercel deploy --prebuilt`)
570
+ │ ○ Docker
571
+ │ ○ Alpic
572
+ │ ○ I don't know yet
573
+ │ ↑/↓ to navigate • Enter: confirm
574
+ └
541
575
  ```
542
576
 
543
577
  Only Vercel leaves anything behind, and it is four lines:
@@ -712,9 +746,10 @@ plumbing unless you pass `--force`, and it runs one way, with nothing to turn an
712
746
  ejected repo back.
713
747
 
714
748
  Your source has to move under `src/` for the compiled server to land where
715
- Skybridge's entry wrapper looks for it. The `rootDir` constraint behind that is
716
- written up in
717
- [the internals](https://github.com/WaniWani-AI/kit/blob/main/INTERNALS.md#why-eject-moves-the-source).
749
+ Skybridge's entry wrapper looks for it. `tsc` derives `rootDir` from the widest
750
+ common ancestor of the files it compiles, so source sitting beside `src/` rather
751
+ than inside it pushes every emitted path down a level and the wrapper's import
752
+ misses.
718
753
 
719
754
  ### The trade eject makes
720
755
 
@@ -737,9 +772,6 @@ What an ejected repo gives up is the generator, and with it:
737
772
  uploads as it is, and a git push builds it on the platform, but no command
738
773
  wraps either.
739
774
  - **`init` asks where an app deploys, and nothing else does.** A transfer, or an
740
- app that answered `Not yet`, writes its own `vercel.json`.
775
+ app that answered `I don't know yet`, writes its own `vercel.json`.
741
776
  - **`useWidget` does not track yet.** Emitting `widget_render` and click events
742
777
  through `useWaniwani` automatically is the next step.
743
-
744
- Template pinning, the CI contract, publishing requirements and the rest of the
745
- gap list are in [INTERNALS.md](https://github.com/WaniWani-AI/kit/blob/main/INTERNALS.md).
package/dist/cli/init.js CHANGED
@@ -14,6 +14,11 @@
14
14
  * ./<name>/, the offered default (the current
15
15
  * folder's name) scaffolds in place
16
16
  *
17
+ * In a terminal it asks three questions — the name, whether the widget comes
18
+ * with the tool, and where the app deploys — and every flag below answers one of
19
+ * them ahead of time, so a question is asked only where nothing has answered it.
20
+ * Piped, on CI, or under `--yes`, nothing is asked and the defaults stand.
21
+ *
17
22
  * --name <name> the MCP server name, default the directory name
18
23
  * --host <host> where it deploys: vercel, alpic, container, none
19
24
  * --minimal config and one tool, no widget
@@ -21,6 +26,10 @@
21
26
  * --no-install skip the dependency install
22
27
  * --force overwrite app files that are already there
23
28
  *
29
+ * What it copies lives in `templates/starter/`, as files rather than as strings
30
+ * in this module, so the repo's own type-checker and formatter run over the
31
+ * scaffold.
32
+ *
24
33
  * Running it inside a repo that already has files is expected and supported.
25
34
  * A `package.json` is merged rather than replaced, a `.gitignore` gains the
26
35
  * lines it lacks, and a `README.md` or `.env.example` that exists is left
@@ -30,15 +39,44 @@
30
39
  import { spawn } from "node:child_process";
31
40
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
41
  import { basename, dirname, join, relative } from "node:path";
33
- import { createInterface } from "node:readline/promises";
34
- import { bold, dim, green, red, yellow } from "./log.js";
35
- import { PACKAGE_VERSION } from "./manifest.js";
36
- import { installable } from "./peers.js";
37
- /** A directory name as an MCP server name: `My App` becomes `my-app`. */
42
+ import { bold, dim, green, yellow } from "./log.js";
43
+ import { PACKAGE_ROOT, PACKAGE_VERSION } from "./manifest.js";
44
+ import { installable, preferred } from "./peers.js";
45
+ import { askOne, askText, canAsk, close, fail, open } from "./prompt.js";
46
+ import { latestVersion } from "./registry.js";
47
+ /**
48
+ * The letters NFD leaves whole, because their Latin shape is not a base letter
49
+ * with a mark on it. Without these, `Straße` reaches the ASCII filter as `stra`
50
+ * and comes out `stra` — a name for a different product.
51
+ */
52
+ const SPELLED_OUT = {
53
+ ß: "ss",
54
+ æ: "ae",
55
+ œ: "oe",
56
+ ø: "o",
57
+ đ: "d",
58
+ ð: "d",
59
+ ł: "l",
60
+ þ: "th",
61
+ };
62
+ /**
63
+ * A directory name as an MCP server name: `My App` becomes `my-app`.
64
+ *
65
+ * NFD splits an accented letter into a base letter and a combining mark, so
66
+ * dropping the marks turns `Café` into `cafe` rather than into `caf`, which is
67
+ * what dropping the composed character would give. What survives is a name npm
68
+ * and a filesystem both accept, and the answer as typed is kept as the title.
69
+ */
38
70
  function slugify(input) {
39
- const slug = input
40
- .trim()
41
- .toLowerCase()
71
+ // Spelled out character by character rather than through a class of the same
72
+ // letters, so the table above is the only place they are written down. A class
73
+ // is where `đ` quietly becomes a `d` that maps to nothing.
74
+ const spelled = [...input.trim().toLowerCase()]
75
+ .map((letter) => SPELLED_OUT[letter] ?? letter)
76
+ .join("");
77
+ const slug = spelled
78
+ .normalize("NFD")
79
+ .replace(/\p{Diacritic}/gu, "")
42
80
  .replace(/[^a-z0-9]+/g, "-")
43
81
  .replace(/^-+|-+$/g, "");
44
82
  return slug || "my-app";
@@ -63,21 +101,25 @@ function cleanTitle(input) {
63
101
  * What a new app depends on.
64
102
  *
65
103
  * Every version is read off this package's own manifest: `@waniwani/kit` at the
66
- * version of the CLI doing the scaffolding, and the four peers at the floors
67
- * this package declares, capped by `installable` so a floor does not install
68
- * the next major on the day it lands. A scaffold that wrote its own numbers
69
- * here would be the one file in the folder that can be wrong the day it is
70
- * created.
104
+ * version of the CLI doing the scaffolding, and the peers at the floors this
105
+ * package declares, capped by `installable` so a floor does not install the
106
+ * next major on the day it lands. A scaffold that wrote its own numbers here
107
+ * would be the one file in the folder that can be wrong the day it is created.
71
108
  *
72
- * `@waniwani/sdk` is written out even though a required peer is auto-installed
73
- * without it, because the app imports it directly — `flows/*.ts` calls
74
- * `createFlow` — and a package you import belongs in your own manifest rather
75
- * than arriving because something else asked for it.
109
+ * `@waniwani/sdk` is the exception, and `sdk` is where it comes from. The floor
110
+ * this package declares is frozen at release, so reading the SDK version off it
111
+ * scaffolds whatever was current when the kit shipped rather than what npm
112
+ * serves — see `preferred` in `./peers.ts`, which is what the caller resolves
113
+ * this through.
114
+ *
115
+ * It is written out at all, rather than left to arrive as a required peer,
116
+ * because the app imports it directly — `flows/*.ts` calls `createFlow` — and a
117
+ * package you import belongs in your own manifest.
76
118
  */
77
- function dependencies() {
119
+ function dependencies(sdk) {
78
120
  return {
79
121
  "@waniwani/kit": `^${PACKAGE_VERSION}`,
80
- "@waniwani/sdk": installable("@waniwani/sdk"),
122
+ "@waniwani/sdk": sdk,
81
123
  react: installable("react"),
82
124
  "react-dom": installable("react-dom"),
83
125
  zod: installable("zod"),
@@ -85,12 +127,52 @@ function dependencies() {
85
127
  }
86
128
  // ------------------------------------------------------------ scaffold content
87
129
  /**
88
- * The tool name and the widget name the scaffold uses. They appear in five
89
- * files, including inside prose the model reads, so they are named once.
130
+ * The tool name and the widget name the scaffold uses. They are the filenames
131
+ * under `templates/starter/`, and the filename is the name in this framework,
132
+ * so renaming one here means renaming the file. `starter()` throws if the two
133
+ * ever disagree.
90
134
  */
91
135
  const TOOL = "search-products";
92
136
  const WIDGET = "product-list";
93
- function packageJson(app) {
137
+ /** Named once: it is looked up in the registry and written into the manifest. */
138
+ const SDK = "@waniwani/sdk";
139
+ /**
140
+ * The starter app, on disk.
141
+ *
142
+ * Real files, the way create-next-app ships `templates/app/ts/` and create-vite
143
+ * ships `template-react-ts/`: copied byte for byte, type-checked by
144
+ * `tsconfig.templates.json` against the kit this workspace builds, and formatted
145
+ * by the same biome config as the rest of the repo.
146
+ *
147
+ * The alternative is TypeScript inside a template literal in this module, which
148
+ * costs it every check the repo runs: `tsc` cannot see the JSX, biome cannot
149
+ * format it, and a change to `defineTool` or `useWidget` breaks the scaffold
150
+ * with nothing going red until somebody runs `waniwani init` by hand. Escaping
151
+ * is the visible half of that — a `\s` in the tool's regex has to be written
152
+ * `\\s`, and every backtick and `${` in the widget carries a backslash that is
153
+ * in no file anyone reads.
154
+ *
155
+ * Nothing here is interpolated, which is what makes that possible. The three
156
+ * files that do carry the app's own name — `package.json`, `waniwani.config.ts`
157
+ * and the README — are still written by this module, because they are data
158
+ * rather than sample code. create-next-app draws the same line, and its
159
+ * template folders carry no `package.json` at all.
160
+ *
161
+ * Two names differ on disk from what lands in the app. npm strips a `.gitignore`
162
+ * out of a published tarball, so the file is `gitignore` here and both CLIs
163
+ * above do the same (`gitignore` for Next, `_gitignore` for Vite); `.env.example`
164
+ * follows it for consistency rather than necessity.
165
+ */
166
+ const STARTER = join(PACKAGE_ROOT, "templates", "starter");
167
+ function starter(file) {
168
+ const source = join(STARTER, file);
169
+ if (!existsSync(source)) {
170
+ throw new Error(`the starter template is missing ${file} — looked in ${STARTER}. ` +
171
+ "An installed copy carries it through the `templates` entry in this package's `files`.");
172
+ }
173
+ return readFileSync(source, "utf-8");
174
+ }
175
+ function packageJson(app, sdk) {
94
176
  return `${JSON.stringify({
95
177
  name: app.name,
96
178
  private: true,
@@ -101,7 +183,7 @@ function packageJson(app) {
101
183
  build: "waniwani build",
102
184
  start: "waniwani start",
103
185
  },
104
- dependencies: dependencies(),
186
+ dependencies: dependencies(sdk),
105
187
  }, null, 2)}\n`;
106
188
  }
107
189
  function appConfig(app) {
@@ -114,166 +196,6 @@ export default defineApp({
114
196
  });
115
197
  `;
116
198
  }
117
- function tool() {
118
- return `import { defineTool } from "@waniwani/kit";
119
- import { z } from "zod";
120
-
121
- /**
122
- * The filename is the tool name, so this file is \`${TOOL}\`. Rename the
123
- * file and the tool renames with it.
124
- *
125
- * Swap CATALOGUE for whatever answers the question for real: a fetch, a
126
- * database, an internal API. \`run\` may be async.
127
- */
128
- const CATALOGUE = [
129
- { id: "aeron", name: "Aeron chair", price: 1290, blurb: "Mesh task chair, twelve-year warranty." },
130
- { id: "sayl", name: "Sayl chair", price: 545, blurb: "Suspension back, the light one." },
131
- { id: "nevi", name: "Nevi sit-stand desk", price: 890, blurb: "Electric, 70 to 120 cm." },
132
- { id: "ollin", name: "Ollin monitor arm", price: 235, blurb: "Single arm, holds up to 9 kg." },
133
- ];
134
-
135
- export default defineTool({
136
- // Shown to humans in connector UIs.
137
- title: "Search the catalogue",
138
- // The only thing the model reads before deciding to call this, so it says
139
- // when to call it and what not to do instead.
140
- description:
141
- "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.",
142
- // Zod shapes, written as plain objects instead of z.object({ ... }).
143
- input: {
144
- query: z.string().describe("What the shopper asked for, in their words, e.g. 'a chair under 600'."),
145
- },
146
- output: {
147
- products: z.array(
148
- z.object({
149
- id: z.string(),
150
- name: z.string(),
151
- price: z.number().describe("Price in euros."),
152
- blurb: z.string(),
153
- }),
154
- ),
155
- },
156
- // Becomes MCP annotations. This tool reads and does nothing else.
157
- hints: { readOnly: true },
158
- run: ({ query }) => {
159
- const terms = query.toLowerCase().split(/\\s+/).filter(Boolean);
160
- const matched = CATALOGUE.filter((product) =>
161
- terms.some((term) => \`\${product.name} \${product.blurb}\`.toLowerCase().includes(term)),
162
- );
163
- // The whole catalogue when nothing matched, so an early conversation has
164
- // something on screen while you are still wiring this up.
165
- return { products: matched.length > 0 ? matched : CATALOGUE };
166
- },
167
- });
168
- `;
169
- }
170
- function widgetContract() {
171
- return `import { defineWidget } from "@waniwani/kit";
172
- import { z } from "zod";
173
-
174
- const product = z.object({
175
- id: z.string(),
176
- name: z.string(),
177
- price: z.number().describe("Price in euros."),
178
- blurb: z.string().describe("One line about the product."),
179
- });
180
-
181
- /**
182
- * The folder name is the tool name, so this widget is \`${WIDGET}\`.
183
- *
184
- * \`data\` is one schema doing three jobs: the tool's input, its structured
185
- * output, and the props \`useWidget()\` hands ui.tsx. Server and UI cannot drift.
186
- *
187
- * This file is imported by the server and by the browser bundle, so it stays
188
- * free of React and CSS. The component sits next to it in ui.tsx.
189
- */
190
- export default defineWidget({
191
- title: "Product list",
192
- description:
193
- "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.",
194
- data: {
195
- query: z.string().describe("What the shopper asked for. Shown as the heading."),
196
- products: z.array(product).describe("Products returned by ${TOOL}, unmodified."),
197
- },
198
- hints: { readOnly: true },
199
- // Text handed to the model alongside the rendered widget. Use it to say what
200
- // the model should not repeat, and what it should wait for.
201
- llmText: (data) =>
202
- \`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.
203
-
204
- Wait for the shopper to pick one, then answer about that product.\`,
205
- });
206
- `;
207
- }
208
- function widgetUi() {
209
- return `import { useLayout, useSendFollowUpMessage, useWidget } from "@waniwani/kit/web";
210
- import widget from "./widget.js";
211
-
212
- const euros = (value: number) =>
213
- new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }).format(value);
214
-
215
- export default function ProductList() {
216
- // Typed off the widget's own \`data\` schema. No generated helpers, no server
217
- // type import.
218
- const { data } = useWidget(widget);
219
- const sendFollowUp = useSendFollowUpMessage();
220
-
221
- // The host hands the colour scheme to the view instead of to the browser, so
222
- // \`prefers-color-scheme\` is the wrong signal and Tailwind's \`dark:\` variant is
223
- // wired to a \`dark\` class (see the template's src/index.css). Every widget puts
224
- // that class on its own root: a view is its own bundle in its own iframe, so
225
- // there is no shared ancestor to hang it off.
226
- const { theme } = useLayout();
227
- const root = theme === "dark" ? "dark" : "";
228
-
229
- // \`data\` arrives as soon as the host has the tool input, which on most hosts is
230
- // before the server has responded. Render optimistically.
231
- if (!data) {
232
- return <div className={\`\${root} font-sans text-sm text-slate-500\`}>Loading…</div>;
233
- }
234
-
235
- return (
236
- <div className={\`\${root} font-sans text-slate-900 dark:text-slate-100\`}>
237
- <h1 className="mb-3 text-lg font-semibold tracking-tight">{data.query}</h1>
238
-
239
- <div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2.5">
240
- {data.products.map((product) => (
241
- <button
242
- type="button"
243
- key={product.id}
244
- // A click becomes a message from the shopper, which is what moves
245
- // the conversation on.
246
- onClick={() => sendFollowUp(\`Tell me more about the \${product.name}.\`)}
247
- 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"
248
- // What the model reads in place of the pixels.
249
- data-llm={\`\${product.name}, \${euros(product.price)}: \${product.blurb}\`}
250
- >
251
- <span className="text-[22px] font-bold tracking-tight">{euros(product.price)}</span>
252
- <span className="font-semibold">{product.name}</span>
253
- <span className="text-[13px] text-slate-500 dark:text-slate-400">{product.blurb}</span>
254
- </button>
255
- ))}
256
- </div>
257
- </div>
258
- );
259
- }
260
- `;
261
- }
262
- function envExample() {
263
- return `# Optional. Without it the app still runs: flows use MemoryKvStore and
264
- # withWaniwani degrades to a no-op. With it, flow state is hosted and tracking
265
- # reaches app.waniwani.ai.
266
- WANIWANI_API_KEY=
267
- WANIWANI_PUBLIC_KEY=
268
- `;
269
- }
270
- function gitignore() {
271
- return `node_modules/
272
- .waniwani/
273
- .env
274
- .env.local
275
- `;
276
- }
277
199
  function readme(app) {
278
200
  return `# ${app.title}
279
201
 
@@ -326,21 +248,21 @@ const HOSTS = [
326
248
  note: "git push, or `vercel deploy --prebuilt`",
327
249
  deploy: ["git push", "vercel deploy --prebuilt # or upload a local build"],
328
250
  },
251
+ {
252
+ id: "container",
253
+ label: "Docker",
254
+ note: "Dockerfile comes from the build",
255
+ deploy: ["waniwani build", "docker build .waniwani"],
256
+ },
329
257
  {
330
258
  id: "alpic",
331
259
  label: "Alpic",
332
260
  note: "alpic.json comes from the build",
333
261
  deploy: ["waniwani build", "cd .waniwani && alpic deploy"],
334
262
  },
335
- {
336
- id: "container",
337
- label: "Docker or self-hosted",
338
- note: "Dockerfile comes from the build",
339
- deploy: ["waniwani build", "docker build .waniwani"],
340
- },
341
263
  {
342
264
  id: "none",
343
- label: "Not yet",
265
+ label: "I don't know yet",
344
266
  note: "nothing written, add it later",
345
267
  deploy: [],
346
268
  },
@@ -358,22 +280,27 @@ function vercelJson() {
358
280
  framework: null,
359
281
  }, null, 2)}\n`;
360
282
  }
361
- function scaffold(app, { minimal, host }) {
283
+ function scaffold(app, { minimal, host, sdk }) {
362
284
  const files = [
363
285
  {
364
286
  path: "package.json",
365
- contents: packageJson(app),
287
+ contents: packageJson(app, sdk),
366
288
  whenPresent: "merge",
367
289
  merge: mergePackageJson,
368
290
  },
369
- { path: ".gitignore", contents: gitignore(), whenPresent: "merge", merge: mergeGitignore },
370
- { path: ".env.example", contents: envExample(), whenPresent: "keep" },
291
+ {
292
+ path: ".gitignore",
293
+ contents: starter("gitignore"),
294
+ whenPresent: "merge",
295
+ merge: mergeGitignore,
296
+ },
297
+ { path: ".env.example", contents: starter("env.example"), whenPresent: "keep" },
371
298
  { path: "README.md", contents: readme(app), whenPresent: "keep" },
372
299
  { path: "waniwani.config.ts", contents: appConfig(app) },
373
- { path: `tools/${TOOL}.ts`, contents: tool() },
300
+ { path: `tools/${TOOL}.ts`, contents: starter(`tools/${TOOL}.ts`) },
374
301
  ];
375
302
  if (!minimal) {
376
- files.push({ path: `widgets/${WIDGET}/widget.ts`, contents: widgetContract() }, { path: `widgets/${WIDGET}/ui.tsx`, contents: widgetUi() });
303
+ files.push({ path: `widgets/${WIDGET}/widget.ts`, contents: starter(`widgets/${WIDGET}/widget.ts`) }, { path: `widgets/${WIDGET}/ui.tsx`, contents: starter(`widgets/${WIDGET}/ui.tsx`) });
377
304
  }
378
305
  // A repo that already answers Vercel its own way keeps that answer: the file
379
306
  // may carry a region, a cron, or a `maxDuration` this has no business
@@ -445,49 +372,17 @@ function write(file, contents) {
445
372
  mkdirSync(dirname(file), { recursive: true });
446
373
  writeFileSync(file, contents);
447
374
  }
448
- /** One question, with the default in parentheses and Enter taking it. */
449
- async function ask(question, fallback) {
450
- const rl = createInterface({ input: process.stdin, output: process.stdout });
451
- try {
452
- const answer = await rl.question(`${question} ${dim(`(${fallback})`)} `);
453
- return answer.trim() || fallback;
454
- }
455
- finally {
456
- rl.close();
457
- }
458
- }
459
375
  /**
460
- * One question with a numbered list, and Enter taking the first option.
376
+ * The two shapes `init` scaffolds.
461
377
  *
462
- * Numbered rather than arrow-driven: this CLI writes plain lines everywhere else,
463
- * and a raw-mode menu is the one piece of terminal state a `waniwani init` piped
464
- * into something would leave behind.
378
+ * The pair is the default because the tool-to-widget hand-off is what nobody
379
+ * guesses from the type signatures, so the folder that demonstrates it is worth
380
+ * more than an empty one. `--minimal` is the same answer given ahead of time.
465
381
  */
466
- async function choose(question, options) {
467
- const first = options[0];
468
- console.log(`\n${bold(question)}`);
469
- for (const [index, option] of options.entries()) {
470
- console.log(` ${dim(`${index + 1}`)} ${option.label} ${dim(`— ${option.note}`)}`);
471
- }
472
- const rl = createInterface({ input: process.stdin, output: process.stdout });
473
- try {
474
- const answer = (await rl.question(`\nPick one ${dim(`(1, ${first.label})`)} `)).trim();
475
- if (!answer)
476
- return first.id;
477
- // A number picks by position; anything else is matched against the labels,
478
- // so typing `vercel` works as well as typing `1`.
479
- const picked = Number(answer);
480
- if (Number.isInteger(picked) && picked >= 1 && picked <= options.length) {
481
- return options[picked - 1].id;
482
- }
483
- const lowered = answer.toLowerCase();
484
- const named = options.find((option) => option.id === lowered || option.label.toLowerCase().startsWith(lowered));
485
- return named?.id ?? first.id;
486
- }
487
- finally {
488
- rl.close();
489
- }
490
- }
382
+ const CONTENTS = [
383
+ { value: "pair", label: "A tool and a widget", hint: "the hand-off between them, wired up" },
384
+ { value: "tool", label: "Just a tool", hint: "a config and one tool, nothing on screen" },
385
+ ];
491
386
  /**
492
387
  * The package manager that invoked this command, which npm, pnpm, yarn and bun
493
388
  * all announce in `npm_config_user_agent`. Nothing to detect from lockfiles: a
@@ -528,12 +423,26 @@ function install(root, manager) {
528
423
  * @returns a process exit code
529
424
  */
530
425
  export async function init(appRoot, flags, { targeted = true } = {}) {
531
- const interactive = process.stdin.isTTY && !flags.yes && !flags.name;
426
+ // One question per unanswered decision, and none at all where there is nobody
427
+ // to answer. A flag counts as answered, which is what keeps `--name x --host
428
+ // vercel` from stopping on a prompt it has nothing left to ask.
429
+ const asking = canAsk() && !flags.yes;
532
430
  const suggested = slugify(basename(appRoot));
533
- // One question, and both fields come out of the answer: `Acme Shop` gives the
534
- // server the name `acme-shop` and keeps `Acme Shop` as the title. An answer
535
- // that is already a slug gets a title with a capital on the front.
536
- const answer = flags.name ?? (interactive ? await ask("App name", suggested) : suggested);
431
+ // Started here and awaited below, so the registry round trip happens behind
432
+ // the questions rather than in front of the first file write. It resolves to
433
+ // null on a slow or absent network and `preferred` falls back to the declared
434
+ // floor, so nothing about this scaffold depends on npm being reachable.
435
+ const publishedSdk = latestVersion(SDK);
436
+ if (asking) {
437
+ open(bold("A new MCP app"));
438
+ }
439
+ // Both fields come out of the one answer: `Acme Shop` gives the server the
440
+ // name `acme-shop` and keeps `Acme Shop` as the title. An answer that is
441
+ // already a slug gets a title with a capital on the front.
442
+ const named = asking && flags.name === undefined;
443
+ const answer = named
444
+ ? await askText("App name", suggested, (value) => value && !/[a-z0-9]/i.test(value) ? "a letter or a number, somewhere" : undefined)
445
+ : (flags.name ?? suggested);
537
446
  const name = slugify(answer);
538
447
  const typed = cleanTitle(answer);
539
448
  const app = { name, title: typed && typed !== name ? typed : titleize(name) };
@@ -542,21 +451,32 @@ export async function init(appRoot, flags, { targeted = true } = {}) {
542
451
  // create-next-app's one question reads. Taking the offered default leaves
543
452
  // everything where it is, since that default is the current folder's own name,
544
453
  // and `waniwani init .` names the current folder outright.
545
- const root = !targeted && interactive && name !== suggested ? join(appRoot, name) : appRoot;
454
+ const root = !targeted && named && name !== suggested ? join(appRoot, name) : appRoot;
455
+ // The widget is what `--minimal` drops, so the flag and the question are the
456
+ // same decision reached two ways.
457
+ const minimal = flags.minimal !== undefined
458
+ ? Boolean(flags.minimal)
459
+ : asking
460
+ ? (await askOne("What should it come with?", CONTENTS)) === "tool"
461
+ : false;
546
462
  // Asked rather than assumed, because the answer decides whether the repo
547
463
  // carries a deploy file at all, and because seeing the four options is how
548
464
  // someone learns the app is not tied to one host.
549
465
  const host = typeof flags.host === "string"
550
466
  ? hostById(flags.host).id
551
- : interactive
552
- ? await choose("Where will this deploy?", HOSTS)
467
+ : asking
468
+ ? await askOne("Where will this deploy?", HOSTS.map((option) => ({
469
+ value: option.id,
470
+ label: option.label,
471
+ hint: option.note,
472
+ })))
553
473
  : hostById(undefined).id;
554
- const files = scaffold(app, { minimal: Boolean(flags.minimal), host });
474
+ const files = scaffold(app, { minimal, host, sdk: preferred(SDK, await publishedSdk) });
555
475
  // Nothing is written until every collision is known, so a refusal leaves the
556
476
  // directory exactly as it was.
557
477
  const clashes = files.filter((file) => !file.whenPresent && existsSync(join(root, file.path)));
558
478
  if (clashes.length > 0 && !flags.force) {
559
- console.error(`\n${red("✗")} ${bold("already an app folder here")}\n`);
479
+ fail(bold("already an app folder here"));
560
480
  for (const file of clashes) {
561
481
  console.error(` ${file.path}`);
562
482
  }
@@ -589,7 +509,7 @@ export async function init(appRoot, flags, { targeted = true } = {}) {
589
509
  write(target, file.contents);
590
510
  actions.push([yellow("~"), file.path, "overwritten"]);
591
511
  }
592
- console.log(`\n${green("✓")} ${bold(app.name)} ${dim(`→ ${root}`)}\n`);
512
+ close(`${bold(app.name)} ${dim(`→ ${root}`)}`);
593
513
  for (const [marker, path, note] of actions) {
594
514
  console.log(` ${marker} ${path}${note ? ` ${dim(note)}` : ""}`);
595
515
  }
@@ -622,7 +542,7 @@ export async function init(appRoot, flags, { targeted = true } = {}) {
622
542
  console.log(` ${ours ? `${manager} run dev` : `${RUNNERS[manager]} waniwani dev`}`);
623
543
  console.log(`\n${bold("Then")}`);
624
544
  console.log(` ${dim("·")} edit ${bold(`tools/${TOOL}.ts`)} to answer with your own data`);
625
- if (!flags.minimal) {
545
+ if (!minimal) {
626
546
  console.log(` ${dim("·")} edit ${bold(`widgets/${WIDGET}/ui.tsx`)} for how it looks on screen`);
627
547
  }
628
548
  console.log(` ${dim("·")} add ${bold("flows/<name>.ts")} for a multi-step conversation`);