@waniwani/kit 0.1.8 → 0.1.9
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 +50 -18
- package/dist/cli/init.js +168 -248
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/peers.js +69 -4
- package/dist/cli/peers.js.map +1 -1
- package/dist/cli/prompt.js +113 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/registry.js +107 -0
- package/dist/cli/registry.js.map +1 -0
- package/dist/cli/template-pin.js +25 -0
- package/dist/cli/template-pin.js.map +1 -0
- package/dist/cli/template.js +14 -18
- package/dist/cli/template.js.map +1 -1
- package/dist/cli/validate.js +39 -3
- package/dist/cli/validate.js.map +1 -1
- package/package.json +8 -8
- package/templates/starter/env.example +4 -0
- package/templates/starter/gitignore +4 -0
- package/templates/starter/tools/search-products.ts +57 -0
- package/templates/starter/waniwani.config.ts +12 -0
- package/templates/starter/widgets/product-list/ui.tsx +51 -0
- package/templates/starter/widgets/product-list/widget.ts +35 -0
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.
|
|
92
|
-
|
|
93
|
-
|
|
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.19.9` picks up
|
|
361
|
+
0.19.10 on the next install and never crosses to 0.20 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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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.
|
|
716
|
-
|
|
717
|
-
|
|
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 `
|
|
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 {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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":
|
|
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
|
|
89
|
-
*
|
|
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
|
-
|
|
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: "
|
|
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
|
-
{
|
|
370
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
376
|
+
* The two shapes `init` scaffolds.
|
|
461
377
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
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 &&
|
|
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
|
-
:
|
|
552
|
-
? await
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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`);
|