@waniwani/kit 0.1.1 → 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/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,6 @@ 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
241
253
  └── lib/ anything else is just modules
242
254
  ```
243
255
 
@@ -251,7 +263,6 @@ unwired.
251
263
  | `tools/<name>.ts` | one MCP tool | `.ts`, `.tsx` and `.mts` are picked up |
252
264
  | `widgets/<name>/` | one MCP tool plus a `ui://` resource | needs `widget.ts` and `ui.tsx` |
253
265
  | `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 |
255
266
  | anything else | plain modules | the CLI leaves it alone |
256
267
 
257
268
  The app folder imports `@waniwani/kit`, plus `@waniwani/sdk` when it uses flows,
@@ -392,15 +403,16 @@ letting it sit there doing nothing:
392
403
  ## Commands
393
404
 
394
405
  ```bash
406
+ waniwani init [dir] # scaffold an app folder, install, ready to dev
395
407
  waniwani check # validate the folder
396
408
  waniwani dev # generate + dev server + regenerate on change
397
409
  waniwani build # generate + production build
398
410
  waniwani start # run the production build
399
- waniwani deploy # generate + vercel deploy
400
411
  waniwani eject [--out dir] # hand the plumbing over and step out
401
412
  ```
402
413
 
403
- Every one of them runs the same four stages before doing its own work.
414
+ `init` writes files and stops there. Every other command runs the same four
415
+ stages before doing its own work.
404
416
 
405
417
  ```mermaid
406
418
  flowchart LR
@@ -409,7 +421,6 @@ flowchart LR
409
421
  tools["tools/*.ts"]
410
422
  widgets["widgets/&lt;name&gt;/<br/>widget.ts + ui.tsx"]
411
423
  flows["flows/*.ts"]
412
- docs["docs/*.md"]
413
424
  end
414
425
 
415
426
  subgraph tpl["WaniWani-AI/mcp-distribution-template (public, separate repo)"]
@@ -433,7 +444,7 @@ flowchart LR
433
444
  app --> scan --> check --> gen --> out
434
445
  runtime -.imported by.-> server
435
446
  raw -.fetched at a pinned SHA, copied byte for byte.-> deployfiles
436
- out --> deploy["dev · build · start<br/>vercel deploy"]
447
+ out --> deploy["dev · build · start"]
437
448
  app -.waniwani eject.-> ejected["a plain repo<br/><i>no CLI, no @waniwani/kit</i>"]
438
449
  ```
439
450
 
@@ -513,9 +524,9 @@ under `src/app/`:
513
524
 
514
525
  ```
515
526
  oney/
516
- ├── src/app/{tools,widgets,flows,docs,lib}/ your code, moved
527
+ ├── src/app/{tools,widgets,flows,lib}/ your code, moved
517
528
  ├── src/_runtime/ the runtime, vendored as source
518
- ├── src/{server,waniwani,docs}.ts entry, registration, inlined docs
529
+ ├── src/{server,waniwani}.ts entry and registration
519
530
  ├── src/views/<widget>.tsx view entries
520
531
  ├── vite.config.ts vercel.json alpic.json bundling and deploy
521
532
  ├── Dockerfile .dockerignore container deploy
@@ -545,8 +556,6 @@ What an ejected repo gives up is the generator, and with it:
545
556
  - **the build check**, so `showWidget("typo")` becomes a runtime failure again
546
557
  - **name-from-filesystem**, so adding a widget means editing `src/waniwani.ts`
547
558
  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
559
  - **runtime fixes**, since `src/_runtime/` is a fork from the moment it lands
551
560
 
552
561
  ## Status
@@ -555,12 +564,12 @@ What an ejected repo gives up is the generator, and with it:
555
564
 
556
565
  - **`@waniwani/cli` owns the `waniwani` bin on npm**, so installing both
557
566
  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.
567
+ - **`waniwani init` scaffolds one shape of app**, a tool with the widget that
568
+ displays it. A flow is not among the files it writes.
569
+ - **Deploying is manual.** `.waniwani/` carries a `vercel.json`, so
570
+ `vercel deploy` inside it works, but no command wraps that.
560
571
  - **`useWidget` does not track yet.** Emitting `widget_render` and click events
561
572
  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
573
 
565
574
  Template pinning, the CI contract, publishing requirements and the rest of the
566
575
  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) {
@@ -599,7 +597,6 @@ function generateServerApp(app, layout, { runtime, styleDomains }) {
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(
@@ -630,7 +627,6 @@ export async function registerApp(server: McpServer): Promise<void> {
630
627
  tools: ${list(app.tools.map((t) => `{ name: "${t.name}", def: tool_${camel(t.name)} }`))},
631
628
  widgets: ${list(app.widgets.map((w) => `{ name: "${w.name}", def: widget_${camel(w.name)} }`))},
632
629
  flows: ${list(app.flows.map((f) => `flow_${camel(f.name)}`))},
633
- docs: ${app.docs.length > 0 ? "docs" : "[]"},
634
630
  // Read off the template's ${STYLE_ENTRY}, which every view imports.
635
631
  styleDomains: ${list(styleDomains.map((origin) => `"${origin}"`))},
636
632
  });
@@ -638,22 +634,6 @@ export async function registerApp(server: McpServer): Promise<void> {
638
634
  `;
639
635
  }
640
636
 
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
637
  function generateWidgetShim(widget, layout) {
658
638
  // From `src/views/` up to `src/`, then out to the app's source.
659
639
  const from = `../${basename(layout.appDir)}`;
@@ -740,7 +720,6 @@ function generateBiome(template, layout) {
740
720
  // Generated and vendored code is not the app author's to fix.
741
721
  `!${layout.runtimeDir}/**`,
742
722
  "!src/server.ts",
743
- "!src/docs.ts",
744
723
  "!src/views/**",
745
724
  ...negative,
746
725
  ],
@@ -861,7 +840,7 @@ function assertSeam(template) {
861
840
 
862
841
  throw new Error(
863
842
  `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` +
843
+ ` tools, widgets and flows would be built and then silently dropped.\n\n` +
865
844
  ` Add to its ${SEAM.file}:\n\n` +
866
845
  ` import { app, registerApp } from "./waniwani.js";\n\n` +
867
846
  ` const server = new McpServer(\n` +
@@ -1003,10 +982,6 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
1003
982
  "src/waniwani.ts",
1004
983
  generateServerApp(app, layout, { runtime, styleDomains: templateStyleDomains(template) }),
1005
984
  );
1006
- if (app.docs.length > 0) {
1007
- emit("src/docs.ts", generateDocs(app, { runtime }));
1008
- }
1009
-
1010
985
  // `src/views/` is shared: the template's own views sit alongside the app's,
1011
986
  // so it cannot be wiped. Only the entries a previous build wrote are
1012
987
  // removed, which is what clears a widget the app has since deleted.
@@ -1046,9 +1021,14 @@ export function generate(app, { template, layout: layoutName = "build", outDir }
1046
1021
  sha: template.sha,
1047
1022
  local: template.local,
1048
1023
  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))),
1024
+ // What survived to the end, copied and generated alike. The
1025
+ // copy is the raw list minus whatever a generated file replaced,
1026
+ // and the generated half is here so that a build which stops
1027
+ // emitting one — `src/docs.ts` when docs left the framework —
1028
+ // cleans up the copy the previous build left behind.
1029
+ files: [...new Set([...fromTemplate, ...GENERATED])].filter((file) =>
1030
+ existsSync(join(root, file)),
1031
+ ),
1052
1032
  // Tracked separately because `src/views/` is shared with the
1053
1033
  // template — the next build needs to know which entries were
1054
1034
  // ours before it removes any.
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
package/cli/index.mjs CHANGED
@@ -2,12 +2,11 @@
2
2
  /**
3
3
  * The `waniwani` CLI.
4
4
  *
5
+ * waniwani init scaffold a new app folder and install it
5
6
  * waniwani check validate the app folder
6
7
  * waniwani dev check, generate, run the dev server, watch for changes
7
- * waniwani tunnel dev, on a public hostname, wired to the playground
8
8
  * waniwani build check, generate, build for production
9
9
  * waniwani start run the production build
10
- * waniwani deploy build, then deploy the generated project to Vercel
11
10
  * waniwani eject write the plumbing into the repo and hand it over
12
11
  *
13
12
  * Every command scans the app folder, validates it, and generates a complete
@@ -19,20 +18,19 @@ import { spawn } from "node:child_process";
19
18
  import { existsSync, readFileSync, watch } from "node:fs";
20
19
  import { dirname, join, resolve } from "node:path";
21
20
  import { fileURLToPath } from "node:url";
22
- import { connectAccount, createClient } from "./account.mjs";
23
21
  import { existingPlumbing, generate } from "./codegen.mjs";
24
- import { banner, bold, dim, endpoint, green, printReport, red, yellow } from "./log.mjs";
22
+ import { init } from "./init.mjs";
23
+ import { banner, bold, dim, green, printReport, red, yellow } from "./log.mjs";
25
24
  import { scanApp } from "./scan.mjs";
26
25
  import { devFilter, FRAMEWORK_ENV, frameworkBin, loadBuildSteps, runBuildSteps, startFilter } from "./framework.mjs";
27
26
  import { DEFAULT_TEMPLATE, describeTemplate, resolveTemplate } from "./template.mjs";
28
- import { findAvailablePort, isPortAvailable, startNamedTunnel, waitForLocalServer } from "./tunnel.mjs";
29
27
  import { validateApp } from "./validate.mjs";
30
28
 
31
29
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
32
30
  const PACKAGE_VERSION = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
33
31
 
34
32
  /** The commands a human sits and watches. `check` and `eject` are often scripted. */
35
- const BANNERED = new Set(["dev", "tunnel", "build", "start", "deploy"]);
33
+ const BANNERED = new Set(["init", "dev", "build", "start"]);
36
34
 
37
35
  /**
38
36
  * Diagnostics about this CLI's own machinery — which template was resolved, how
@@ -67,11 +65,8 @@ function binPath(from) {
67
65
  *
68
66
  * `shell` is for the framework's build steps, which name their command as one
69
67
  * string rather than an argv.
70
- *
71
- * `onChild` hands the process back to the caller. `tunnel` keeps working while
72
- * the dev server runs and has to be able to take it down with it.
73
68
  */
74
- function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter, onChild } = {}) {
69
+ function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilter } = {}) {
75
70
  return new Promise((resolvePromise) => {
76
71
  const child = spawn(command, args, {
77
72
  cwd,
@@ -79,7 +74,6 @@ function run(command, args, { cwd, env, shell = false, stdoutFilter, stderrFilte
79
74
  stdio: ["inherit", stdoutFilter ? "pipe" : "inherit", stderrFilter ? "pipe" : "inherit"],
80
75
  env: { ...process.env, PATH: binPath(cwd), ...FRAMEWORK_ENV, ...env },
81
76
  });
82
- onChild?.(child);
83
77
  for (const [stream, filter] of [
84
78
  [child.stdout, stdoutFilter],
85
79
  [child.stderr, stderrFilter],
@@ -239,9 +233,6 @@ async function eject(appRoot, flags) {
239
233
  }
240
234
  console.log(` ${dim("·")} the runtime is now yours, vendored as source in ${bold("src/_runtime/")}`);
241
235
  console.log(` ${dim("·")} @waniwani/kit imports point at src/_runtime/ — drop the dependency`);
242
- console.log(
243
- ` ${dim("·")} docs/*.md are inlined into ${bold("src/docs.ts")} — regenerate by hand from here on`,
244
- );
245
236
  console.log(
246
237
  ` ${dim("·")} widgets/<name>/ no longer becomes a view — add ${bold("src/views/<name>.tsx")} by hand`,
247
238
  );
@@ -277,7 +268,7 @@ function watchApp(appRoot, template) {
277
268
  }, 120);
278
269
  };
279
270
 
280
- for (const dir of ["tools", "widgets", "flows", "docs"]) {
271
+ for (const dir of ["tools", "widgets", "flows"]) {
281
272
  const path = join(appRoot, dir);
282
273
  if (existsSync(path)) {
283
274
  watch(path, { recursive: true }, rebuild);
@@ -297,176 +288,23 @@ function watchApp(appRoot, template) {
297
288
  * the framework's auto-open of its own DevTools page in the browser; the URL is
298
289
  * printed instead.
299
290
  */
300
- function devServer(outDir, { env, onChild } = {}) {
291
+ function devServer(outDir) {
301
292
  return run("node", [frameworkBin(), "dev", "--plain"], {
302
293
  cwd: outDir,
303
- env,
304
294
  stderrFilter: devFilter(),
305
- onChild,
306
295
  });
307
296
  }
308
297
 
309
- const HEARTBEAT_MS = 30_000;
310
- const SESSION_DELETE_TIMEOUT_MS = 2_000;
311
- const DEFAULT_DEV_PORT = 3000;
312
-
313
- function parsePort(raw) {
314
- const port = typeof raw === "string" ? Number(raw) : Number.NaN;
315
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
316
- throw new Error("--port wants an integer between 1 and 65535");
317
- }
318
- return port;
319
- }
320
-
321
- /**
322
- * The port the dev server takes, which is also the port the tunnel's ingress is
323
- * pointed at.
324
- *
325
- * An explicit `--port` is taken at its word and fails when it is busy, since the
326
- * caller asked for that one. Otherwise the first free port from the configured
327
- * default is used: the alternative is a dev server that quietly moves to 3001
328
- * while the tunnel forwards to 3000.
329
- */
330
- async function resolveDevPort(flags, configured) {
331
- // Presence, not truthiness: a bare `--port` with nothing after it parses as an
332
- // undefined value, and picking a port anyway would ignore what was asked for.
333
- if ("port" in flags) {
334
- const port = parsePort(flags.port);
335
- if (!(await isPortAvailable(port))) {
336
- throw new Error(`port ${port} is in use: free it or pass a different --port`);
337
- }
338
- return port;
339
- }
340
- const start = configured ?? DEFAULT_DEV_PORT;
341
- const port = await findAvailablePort(start);
342
- if (port !== start) {
343
- console.log(dim(`[waniwani] port ${start} is in use, using ${port}`));
344
- }
345
- return port;
346
- }
347
-
348
- /** `--open`. A dev loop prints its URLs and leaves the browser to the developer. */
349
- function openBrowser(url) {
350
- const [command, args] =
351
- process.platform === "darwin"
352
- ? ["open", [url]]
353
- : process.platform === "win32"
354
- ? ["cmd", ["/c", "start", "", url]]
355
- : ["xdg-open", [url]];
356
- spawn(command, args, { stdio: "ignore", detached: true }).unref();
357
- }
298
+ /** Flags that take a value; everything else is a boolean switch. */
299
+ const VALUE_FLAGS = new Set(["out", "template", "name"]);
358
300
 
359
301
  /**
360
- * The dev loop, reachable from the internet and wired to the agent's playground.
361
- *
362
- * Everything `dev` does happens here too, on a port this command picks. What it
363
- * adds is the round trip to app.waniwani.ai: a connector token for the agent's
364
- * `<slug>.waniwani.dev` hostname, cloudflared running against it, and a dev
365
- * session held open by a heartbeat. The session is what points the playground at
366
- * this machine while the command runs, and at the deployed agent once it stops.
302
+ * `--out dir` / `--template=github:o/r#ref` alongside a positional app directory.
367
303
  *
368
- * Which account and which agent come from the two files `@waniwani/cli` and the
369
- * SDK already share (see ./account.mjs), and a missing one sends the developer
370
- * through that CLI's login or connect flow on the way in.
304
+ * `--no-install` sets `install` to false, so a switch that is on by default is
305
+ * read as one flag with two states instead of two flags a caller can set to
306
+ * contradict each other.
371
307
  */
372
- async function tunnel(appRoot, flags) {
373
- const account = await connectAccount(appRoot);
374
- const prepared = await prepare(appRoot, flags);
375
- if (!prepared) return 1;
376
-
377
- const port = await resolveDevPort(flags, account.devPort);
378
- const client = createClient(account.apiUrl);
379
- const sessions = `/api/mcp/projects/${account.projectId}/dev-session`;
380
-
381
- let child = null;
382
- let session = null;
383
- let open = null;
384
- let heartbeat = null;
385
- let closing = false;
386
-
387
- /**
388
- * Take down the session, the tunnel and the dev server, in that order.
389
- *
390
- * The session goes first and on a timeout: one left behind keeps the
391
- * playground calling a hostname that has stopped answering until the
392
- * heartbeat ages out server-side, and a slow API call is not a reason to
393
- * hold the terminal.
394
- */
395
- const shutdown = async (code) => {
396
- if (closing) return code;
397
- closing = true;
398
- clearInterval(heartbeat);
399
- if (session) {
400
- await Promise.race([
401
- client.delete(`${sessions}/${session}`).catch(() => {}),
402
- new Promise((resolveTimeout) => setTimeout(resolveTimeout, SESSION_DELETE_TIMEOUT_MS)),
403
- ]);
404
- }
405
- open?.stop();
406
- if (child?.exitCode === null) child.kill("SIGTERM");
407
- return code;
408
- };
409
-
410
- for (const signal of ["SIGINT", "SIGTERM"]) {
411
- process.once(signal, () => {
412
- void shutdown(0).then((code) => process.exit(code));
413
- });
414
- }
415
-
416
- watchApp(appRoot, prepared.template);
417
- const devLoop = devServer(prepared.outDir, {
418
- env: { PORT: String(port) },
419
- onChild: (spawned) => {
420
- child = spawned;
421
- },
422
- });
423
-
424
- // A dev server that dies on startup, from a port taken in the meantime or a
425
- // broken vite config, would otherwise sit out the readiness timeout.
426
- const earlyExit = devLoop.then((code) =>
427
- Promise.reject(new Error(`the dev server exited with code ${code} before it was ready`)),
428
- );
429
-
430
- try {
431
- console.log(dim(`[waniwani] waiting for the dev server on port ${port}…`));
432
- try {
433
- await Promise.race([waitForLocalServer(`http://localhost:${port}/`), earlyExit]);
434
- } finally {
435
- // The race is settled either way, so the loser's rejection needs an owner.
436
- earlyExit.catch(() => {});
437
- }
438
-
439
- console.log(dim("[waniwani] opening the tunnel…"));
440
- open = await startNamedTunnel(await client.post(`/api/mcp/projects/${account.projectId}/tunnel`, { port }));
441
-
442
- // Creating the session takes no payload: the hostname the playground
443
- // routes to is the tunnel's, and the API already holds it.
444
- session = (await client.post(sessions, {})).id;
445
- heartbeat = setInterval(() => {
446
- // Silent on failure. A beat that does not land costs the session, and
447
- // the playground falls back to the deployed agent.
448
- void client.patch(`${sessions}/${session}`).catch(() => {});
449
- }, HEARTBEAT_MS);
450
-
451
- console.log("");
452
- console.log(endpoint("public", `${open.publicUrl}/mcp`));
453
- console.log(endpoint("try", account.playgroundUrl));
454
- console.log("");
455
- if (flags.open) {
456
- openBrowser(account.playgroundUrl);
457
- }
458
-
459
- return await shutdown(await devLoop);
460
- } catch (error) {
461
- await shutdown(1);
462
- throw error;
463
- }
464
- }
465
-
466
- /** Flags that take a value; everything else is a boolean switch. */
467
- const VALUE_FLAGS = new Set(["out", "template", "port"]);
468
-
469
- /** `--out dir` / `--template=github:o/r#ref` alongside a positional app directory. */
470
308
  function parseArgs(argv) {
471
309
  const flags = {};
472
310
  const positional = [];
@@ -477,6 +315,10 @@ function parseArgs(argv) {
477
315
  continue;
478
316
  }
479
317
  const [name, inline] = arg.slice(2).split("=");
318
+ if (name.startsWith("no-")) {
319
+ flags[name.slice(3)] = false;
320
+ continue;
321
+ }
480
322
  flags[name] = VALUE_FLAGS.has(name) ? (inline ?? argv[++i]) : true;
481
323
  }
482
324
  return { flags, positional };
@@ -491,6 +333,12 @@ async function main() {
491
333
  banner(PACKAGE_VERSION);
492
334
  }
493
335
 
336
+ if (command === "init") {
337
+ // Whether a directory was named matters only here: with none, the answer to
338
+ // the one question decides where the app goes.
339
+ process.exit(await init(appRoot, flags, { targeted: positional.length > 0 }));
340
+ }
341
+
494
342
  if (command === "check") {
495
343
  const app = scanApp(appRoot);
496
344
  const report = await validateApp(app);
@@ -505,10 +353,6 @@ async function main() {
505
353
  process.exit(await devServer(prepared.outDir));
506
354
  }
507
355
 
508
- if (command === "tunnel") {
509
- process.exit(await tunnel(appRoot, flags));
510
- }
511
-
512
356
  if (command === "build") {
513
357
  const prepared = await prepare(appRoot, flags);
514
358
  if (!prepared) process.exit(1);
@@ -529,22 +373,12 @@ async function main() {
529
373
  );
530
374
  }
531
375
 
532
- if (command === "deploy") {
533
- const prepared = await prepare(appRoot, flags);
534
- if (!prepared) process.exit(1);
535
- console.log(dim("[waniwani] deploying the generated project to Vercel…"));
536
- const code = await run("vercel", ["deploy", ...(flags.prod ? ["--prod"] : [])], {
537
- cwd: prepared.outDir,
538
- });
539
- process.exit(code);
540
- }
541
-
542
376
  if (command === "eject") {
543
377
  process.exit(await eject(appRoot, flags));
544
378
  }
545
379
 
546
380
  console.error(red(`unknown command: ${command}`));
547
- console.error(dim("usage: waniwani <check|dev|tunnel|build|start|deploy|eject> [dir]"));
381
+ console.error(dim("usage: waniwani <init|check|dev|build|start|eject> [dir]"));
548
382
  process.exit(1);
549
383
  }
550
384