create-curb.js 0.0.0 → 1.15.0-preview.1

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.
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env node
2
+ // `npm create curb.js my-agent`: scaffolds a new agent project from templates/default, pinned
3
+ // to the curb.js release with this package's version, so `npm create curb.js@preview` gives a
4
+ // project on the matching preview. It asks nothing: the project runs on KugelAudio
5
+ // (LLM, speech-to-text, text-to-speech) unless flags pick other providers, and
6
+ // provider API keys come from the environment.
7
+ import { execFileSync, spawn } from "node:child_process";
8
+ import {
9
+ cpSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ renameSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { basename, dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import pc from "picocolors";
20
+ import { blockedInstallScripts, installScriptsFix } from "./install-scripts.js";
21
+ import { exitOnUnsupportedNode } from "./node-version.js";
22
+ import { npmCommand } from "./npm-command.js";
23
+ import { scaffoldArgs } from "./scaffold-args.js";
24
+
25
+ exitOnUnsupportedNode(new URL("../package.json", import.meta.url));
26
+
27
+ const USAGE = `Usage: npm create curb.js <directory> -- [options]
28
+
29
+ Creates a new agent framework project in <directory> (my-agent when none is named).
30
+
31
+ The project runs on KugelAudio for speech-to-text, the LLM and text-to-speech,
32
+ all on one login. The options below pick other providers; anything you omit
33
+ keeps its default, and provider API keys are read from the environment.
34
+
35
+ Options:
36
+ --stt <provider> kugelaudio | azure | deepgram | elevenlabs | openai (default kugelaudio)
37
+ --llm <provider> kugelaudio | anthropic | openai | openai-compatible (default kugelaudio)
38
+ --tts <provider> kugelaudio | cartesia | elevenlabs | openai (default kugelaudio)
39
+ --model <id> LLM model id (default qwen3.8-27b on KugelAudio)
40
+ --stt-model <id> STT model id (provider default otherwise)
41
+ --tts-model <id> TTS model id (provider default otherwise)
42
+ --llm-base-url <url> Endpoint for --llm openai-compatible
43
+ --framework <spec> Dependency spec for the "curb" package (a version,
44
+ file:/path, or tarball URL). Defaults to the local
45
+ framework checkout, else the curb.js release that
46
+ matches this package.
47
+ --skip-install Do not run npm install.
48
+ --skip-git Do not initialize a git repository.
49
+ --yes, -y Accepted and ignored (nothing is asked).
50
+ --help, -h Show this help.
51
+
52
+ Provider API keys (read from the environment):
53
+ KUGELAUDIO_API_KEY ANTHROPIC_API_KEY OPENAI_API_KEY AZURE_SPEECH_KEY
54
+ AZURE_SPEECH_REGION DEEPGRAM_API_KEY ELEVEN_API_KEY CARTESIA_API_KEY
55
+
56
+ npm create curb.js@preview creates the project on the newest preview.
57
+
58
+ Example (into the empty current folder; .env gets an empty line for each other
59
+ provider key the environment does not hold, for the user to fill in; KugelAudio
60
+ runs on npx curb login, or on a KUGELAUDIO_API_KEY you set):
61
+ npm create curb.js .
62
+ `;
63
+
64
+ function fail(message) {
65
+ console.error(message);
66
+ process.exit(1);
67
+ }
68
+
69
+ // ---------------------------------------------------------------- arguments
70
+ const args = scaffoldArgs(process.argv.slice(2));
71
+ let targetArg;
72
+ let frameworkSpec;
73
+ let skipInstall = false;
74
+ let skipGit = false;
75
+ // Provider/model selections passed as flags.
76
+ const cli = {};
77
+
78
+ for (let index = 0; index < args.length; index += 1) {
79
+ const arg = args[index];
80
+ const takeValue = (name) => {
81
+ const value = args[index + 1];
82
+ if (value === undefined || value.startsWith("-")) fail(`${name} needs a value.\n\n${USAGE}`);
83
+ index += 1;
84
+ return value;
85
+ };
86
+ if (arg === "--framework") frameworkSpec = takeValue("--framework");
87
+ else if (arg === "--stt") cli.stt = takeValue("--stt");
88
+ else if (arg === "--llm") cli.llm = takeValue("--llm");
89
+ else if (arg === "--tts") cli.tts = takeValue("--tts");
90
+ else if (arg === "--model") cli.model = takeValue("--model");
91
+ else if (arg === "--stt-model") cli.sttModel = takeValue("--stt-model");
92
+ else if (arg === "--tts-model") cli.ttsModel = takeValue("--tts-model");
93
+ else if (arg === "--llm-base-url") cli.llmBaseUrl = takeValue("--llm-base-url");
94
+ else if (arg === "--yes" || arg === "-y") continue; // nothing is asked; kept so existing commands still run
95
+ else if (arg === "--skip-install") skipInstall = true;
96
+ else if (arg === "--skip-git") skipGit = true;
97
+ else if (arg === "--help" || arg === "-h") {
98
+ console.log(USAGE);
99
+ process.exit(0);
100
+ } else if (arg.startsWith("-")) fail(`Unknown option "${arg}".\n\n${USAGE}`);
101
+ else if (targetArg) fail(`Unexpected argument "${arg}".\n\n${USAGE}`);
102
+ else targetArg = arg;
103
+ }
104
+
105
+ if (!targetArg) fail(USAGE);
106
+
107
+ const targetDir = resolve(targetArg);
108
+
109
+ // Package dir of this CLI, used to find bundled templates.
110
+ const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
111
+ // A repo checkout installs the sibling framework package; a published copy, curb.js from npm.
112
+ const localFramework = resolve(pkgDir, "../framework");
113
+ const repoCheckout = existsSync(join(localFramework, "package.json"));
114
+
115
+ const appName = basename(targetDir);
116
+
117
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(appName)) {
118
+ fail(
119
+ `"${appName}" is not a valid project name — use lowercase letters, digits, ".", "_", "-" (npm package name rules).`,
120
+ );
121
+ }
122
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
123
+ fail(`${targetDir} already exists and is not empty. Choose a new directory.`);
124
+ }
125
+
126
+ // ------------------------------------------------- resolve the framework dep
127
+ // A project imports "curb" and installs it from npm as curb.js, because npm's `curb` is someone
128
+ // else's package. A published copy pins its own version, so the two always match. (npm installs
129
+ // file: directories as symlinks, so a repo-linked project behaves like a workspace member.)
130
+ const CURB_ON_NPM = "npm:curb.js";
131
+ if (!frameworkSpec) {
132
+ const { version } = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"));
133
+ frameworkSpec = repoCheckout ? `file:${localFramework}` : `${CURB_ON_NPM}@${version}`;
134
+ } else if (/^\d/.test(frameworkSpec)) {
135
+ frameworkSpec = `${CURB_ON_NPM}@${frameworkSpec}`;
136
+ }
137
+
138
+ // ------------------------------------------------------------ provider table
139
+ // A provider's API key is read ONCE and reused for every pipeline slot it
140
+ // covers (ElevenLabs STT+TTS, or an OpenAI LLM+STT, need a single key).
141
+ const KEY_ENV = {
142
+ anthropic: "ANTHROPIC_API_KEY",
143
+ openai: "OPENAI_API_KEY",
144
+ azure: "AZURE_SPEECH_KEY",
145
+ kugelaudio: "KUGELAUDIO_API_KEY",
146
+ deepgram: "DEEPGRAM_API_KEY",
147
+ elevenlabs: "ELEVEN_API_KEY",
148
+ cartesia: "CARTESIA_API_KEY",
149
+ };
150
+
151
+ // The providers each flag accepts (first = default).
152
+ const STT_PROVIDERS = ["kugelaudio", "azure", "deepgram", "elevenlabs", "openai"];
153
+ const LLM_PROVIDERS = ["kugelaudio", "anthropic", "openai", "openai-compatible"];
154
+ const TTS_PROVIDERS = ["kugelaudio", "cartesia", "elevenlabs", "openai"];
155
+
156
+ // Each provider's default model; a provider missing here has a single model and
157
+ // takes none. Model ids verified against the LiveKit plugin type definitions and
158
+ // the KugelAudio model docs.
159
+ const DEFAULT_STT_MODEL = { deepgram: "nova-3", openai: "gpt-realtime-whisper" };
160
+ const DEFAULT_TTS_MODEL = {
161
+ kugelaudio: "kugel-3.5",
162
+ cartesia: "sonic-3",
163
+ elevenlabs: "eleven_turbo_v2_5",
164
+ openai: "tts-1",
165
+ };
166
+ const defaultSttModel = (provider) => DEFAULT_STT_MODEL[provider] ?? null;
167
+ const defaultTtsModel = (provider) => DEFAULT_TTS_MODEL[provider] ?? null;
168
+ // KugelAudio's EU-hosted LLMs, the models `kugelaudio.llm({ model })` takes (first = default).
169
+ const KUGELAUDIO_LLM_MODELS = ["qwen3.8-27b", "mistral-medium-3.5-128b"];
170
+ const KUGELAUDIO_LLM_MODEL = KUGELAUDIO_LLM_MODELS[0];
171
+ const DEFAULT_LLM_BASE_URL = "http://127.0.0.1:11434/v1";
172
+
173
+ // LiveKit plugin packages per provider — ONLY selected ones are installed.
174
+ // azure + kugelaudio are framework-native and need no plugin.
175
+ const PLUGIN_VERSION = "^1.5.1";
176
+ const PLUGIN_PACKAGES = {
177
+ deepgram: "@livekit/agents-plugin-deepgram",
178
+ elevenlabs: "@livekit/agents-plugin-elevenlabs",
179
+ cartesia: "@livekit/agents-plugin-cartesia",
180
+ openai: "@livekit/agents-plugin-openai",
181
+ };
182
+
183
+ /** The model an LLM provider starts with unless --model names one. */
184
+ const defaultLlmModel = (provider) =>
185
+ ({ kugelaudio: KUGELAUDIO_LLM_MODEL, anthropic: "claude-haiku-4-5", openai: "gpt-5.2" })[provider] ?? "llama3.3";
186
+
187
+ // ------------------------------------------------------------------ selection
188
+ const answers = {
189
+ llm: "kugelaudio",
190
+ agentModel: KUGELAUDIO_LLM_MODEL,
191
+ llmBaseUrl: DEFAULT_LLM_BASE_URL,
192
+ llmApiKey: "",
193
+ stt: "kugelaudio",
194
+ sttModel: null, // provider default (null when the provider has a single model)
195
+ tts: "kugelaudio",
196
+ ttsModel: defaultTtsModel("kugelaudio"),
197
+ keys: {}, // env name -> value (provider API keys + AZURE_SPEECH_REGION)
198
+ };
199
+
200
+ /** Ordered list of providers whose shared API key the project needs, de-duplicated
201
+ * across the STT/LLM/TTS picks (OpenAI as STT+LLM → one key). */
202
+ function keyProvidersFor(picks) {
203
+ const order = [];
204
+ const seen = new Set();
205
+ for (const id of picks) {
206
+ if (!id || id === "openai-compatible") continue;
207
+ if (seen.has(id)) continue;
208
+ seen.add(id);
209
+ order.push(id);
210
+ }
211
+ return order;
212
+ }
213
+
214
+ /** The .env map from the selection. The .env holds keys only; WHICH providers run is
215
+ * written into src/agent/agent.tsx as <Agent> props. */
216
+ function finalizeEnv() {
217
+ const env = { ...answers.keys };
218
+ if (answers.llm === "openai-compatible" && answers.llmApiKey) env.AGENT_LLM_API_KEY = answers.llmApiKey;
219
+ return env;
220
+ }
221
+
222
+ /** The language the template agent speaks: its prompts and kugelaudio.tts()'s default voice
223
+ * are English. Kugel Ear is told so, because without a language it guesses between German
224
+ * and English. */
225
+ const TEMPLATE_LANGUAGE = "en";
226
+
227
+ /** The picked providers as `curb/providers` source: the import line and the <Agent> tag.
228
+ * A model is written only when it differs from the provider's own default. */
229
+ function providerSource(a) {
230
+ const q = (value) => JSON.stringify(value);
231
+ // A factory's options object as source, with only the options that are set ("" for none).
232
+ const options = (entries) => {
233
+ const set = Object.entries(entries).filter(([, value]) => value);
234
+ return set.length === 0 ? "" : `{ ${set.map(([key, value]) => `${key}: ${q(value)}`).join(", ")} }`;
235
+ };
236
+ const changed = (model, fallback) => (model && model !== fallback ? model : null);
237
+ const factories = new Set([a.stt, a.tts]);
238
+ let llm;
239
+ if (a.llm === "openai-compatible") {
240
+ factories.add("openaiCompatible");
241
+ llm = `openaiCompatible({ baseURL: ${q(a.llmBaseUrl || DEFAULT_LLM_BASE_URL)}, model: ${q(a.agentModel)} })`;
242
+ } else if (a.llm === "kugelaudio") {
243
+ factories.add("kugelaudio");
244
+ llm = `kugelaudio.llm(${options({ model: changed(a.agentModel, KUGELAUDIO_LLM_MODEL) })})`;
245
+ } else {
246
+ factories.add(a.llm);
247
+ llm = `${a.llm}(${q(a.agentModel)})`;
248
+ }
249
+ const stt = `${a.stt}.stt(${options({
250
+ model: changed(a.sttModel, defaultSttModel(a.stt)),
251
+ language: a.stt === "kugelaudio" ? TEMPLATE_LANGUAGE : null,
252
+ })})`;
253
+ const tts = `${a.tts}.tts(${options({ model: changed(a.ttsModel, defaultTtsModel(a.tts)) })})`;
254
+ return {
255
+ importLine: `import { ${[...factories].sort().join(", ")} } from "curb/providers";`,
256
+ agentTag: `<Agent llm={${llm}} stt={${stt}} tts={${tts}}>`,
257
+ };
258
+ }
259
+
260
+ // Apply --stt/--llm/--tts/--model flags over the defaults. Unknown provider
261
+ // values fail loudly (never a silent fallback to the default).
262
+ function applyCliSelections() {
263
+ const validate = (label, value, allowed) => {
264
+ if (!allowed.includes(value)) {
265
+ fail(`Invalid --${label} "${value}". Choose one of: ${allowed.join(", ")}.`);
266
+ }
267
+ return value;
268
+ };
269
+ if (cli.stt) answers.stt = validate("stt", cli.stt, STT_PROVIDERS);
270
+ if (cli.llm) {
271
+ answers.llm = validate("llm", cli.llm, LLM_PROVIDERS);
272
+ // Model default follows the chosen LLM unless --model overrides it.
273
+ answers.agentModel = defaultLlmModel(answers.llm);
274
+ }
275
+ if (cli.tts) answers.tts = validate("tts", cli.tts, TTS_PROVIDERS);
276
+ if (cli.model) answers.agentModel = cli.model;
277
+ if (answers.llm === "kugelaudio") validate("model", answers.agentModel, KUGELAUDIO_LLM_MODELS);
278
+ if (cli.llmBaseUrl) answers.llmBaseUrl = cli.llmBaseUrl;
279
+ // Models default to each provider's own default when not overridden.
280
+ answers.sttModel = cli.sttModel ?? defaultSttModel(answers.stt);
281
+ answers.ttsModel = cli.ttsModel ?? defaultTtsModel(answers.tts);
282
+ }
283
+
284
+ function seedKeysFromEnv() {
285
+ for (const id of keyProvidersFor([answers.stt, answers.llm, answers.tts])) {
286
+ const keyEnv = KEY_ENV[id];
287
+ if (process.env[keyEnv]) answers.keys[keyEnv] = process.env[keyEnv];
288
+ }
289
+ if (answers.stt === "azure" || answers.tts === "azure") {
290
+ answers.keys.AZURE_SPEECH_REGION = process.env.AZURE_SPEECH_REGION || "eastus";
291
+ }
292
+ if (answers.llm === "openai-compatible" && process.env.AGENT_LLM_API_KEY) {
293
+ answers.llmApiKey = process.env.AGENT_LLM_API_KEY;
294
+ }
295
+ }
296
+
297
+ applyCliSelections();
298
+ seedKeysFromEnv();
299
+ const envValues = finalizeEnv();
300
+
301
+ // -------------------------------------------------------------- copy template
302
+ const templateDir = resolve(dirname(fileURLToPath(import.meta.url)), "../templates/default");
303
+ mkdirSync(targetDir, { recursive: true });
304
+ cpSync(templateDir, targetDir, { recursive: true });
305
+
306
+ // npm strips .gitignore from published tarballs, so the template ships it without the
307
+ // leading dot. The project carries no .npmrc: hosted builds refuse one.
308
+ renameSync(join(targetDir, "gitignore"), join(targetDir, ".gitignore"));
309
+ renameSync(join(targetDir, "env.example"), join(targetDir, ".env.example"));
310
+
311
+ // package.json placeholders + the LiveKit plugin deps for the SELECTED voice
312
+ // providers only (none for the native azure/kugelaudio paths).
313
+ const pluginDeps = {};
314
+ for (const id of new Set([answers.stt, answers.tts])) {
315
+ if (PLUGIN_PACKAGES[id]) pluginDeps[PLUGIN_PACKAGES[id]] = PLUGIN_VERSION;
316
+ }
317
+
318
+ const manifestPath = join(targetDir, "package.json");
319
+ const manifest = JSON.parse(
320
+ readFileSync(manifestPath, "utf8")
321
+ .replaceAll("{{APP_NAME}}", appName)
322
+ .replaceAll("{{FRAMEWORK_SPEC}}", frameworkSpec),
323
+ );
324
+ manifest.dependencies = Object.fromEntries(
325
+ Object.entries({ ...manifest.dependencies, ...pluginDeps }).sort(([a], [b]) => a.localeCompare(b)),
326
+ );
327
+ // The keys a hosted build refuses to start without: those of the SELECTED providers, not the
328
+ // template's defaults (an Azure STT project must also be asked for its Azure key).
329
+ const requiredKeys = [
330
+ ...new Set(keyProvidersFor([answers.llm, answers.stt, answers.tts]).map((id) => KEY_ENV[id])),
331
+ ].sort();
332
+ manifest.curb.requiredRuntimeSecrets = requiredKeys;
333
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
334
+
335
+ // README.md's title is the project name.
336
+ const readmePath = join(targetDir, "README.md");
337
+ writeFileSync(readmePath, readFileSync(readmePath, "utf8").replaceAll("{{APP_NAME}}", appName));
338
+
339
+ // agent.tsx: the picked providers become the <Agent> props. The template's default line
340
+ // is replaced whole, so a provider other than the defaults imports its own factory.
341
+ const TEMPLATE_PROVIDER_IMPORT = 'import { kugelaudio } from "curb/providers";';
342
+ const TEMPLATE_AGENT_TAG =
343
+ '<Agent llm={kugelaudio.llm()} stt={kugelaudio.stt({ language: "en" })} tts={kugelaudio.tts()}>';
344
+ const agentPath = join(targetDir, "src/agent/agent.tsx");
345
+ const templateAgent = readFileSync(agentPath, "utf8");
346
+ if (!templateAgent.includes(TEMPLATE_PROVIDER_IMPORT) || !templateAgent.includes(TEMPLATE_AGENT_TAG)) {
347
+ fail("create-curb.js: templates/default/src/agent/agent.tsx no longer has the provider lines this installer rewrites.");
348
+ }
349
+ const providers = providerSource(answers);
350
+ writeFileSync(
351
+ agentPath,
352
+ templateAgent
353
+ // Function replacers: a model id containing "$&" must land verbatim.
354
+ .replace(TEMPLATE_PROVIDER_IMPORT, () => providers.importLine)
355
+ .replace(TEMPLATE_AGENT_TAG, () => providers.agentTag),
356
+ );
357
+
358
+ // .env: every key entered, plus an empty line for each other key the selected providers
359
+ // need, in a stable order. Always written, so the keys have one place to go. KugelAudio's key
360
+ // is written only when the setup got one: without it the project runs on `npx curb login`.
361
+ const ENV_ORDER = [
362
+ "ANTHROPIC_API_KEY",
363
+ "AGENT_LLM_API_KEY",
364
+ "OPENAI_API_KEY",
365
+ "AZURE_SPEECH_KEY",
366
+ "AZURE_SPEECH_REGION",
367
+ "KUGELAUDIO_API_KEY",
368
+ "DEEPGRAM_API_KEY",
369
+ "ELEVEN_API_KEY",
370
+ "CARTESIA_API_KEY",
371
+ ];
372
+ const envLines = ENV_ORDER.filter(
373
+ (name) => envValues[name] || (requiredKeys.includes(name) && name !== "KUGELAUDIO_API_KEY"),
374
+ ).map(
375
+ (name) => `${name}=${envValues[name] ?? ""}`,
376
+ );
377
+ writeFileSync(join(targetDir, ".env"), envLines.map((line) => `${line}\n`).join(""));
378
+
379
+ // ---------------------------------------------------------- output + install
380
+ /** The curb orange (256-color ANSI; picocolors has no orange), plain when color is off. */
381
+ const orange = (text) => (pc.isColorSupported ? `\x1b[38;5;208m${text}\x1b[39m` : text);
382
+
383
+ function gitInit() {
384
+ const inRepo = (() => {
385
+ try {
386
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: targetDir, stdio: "ignore" });
387
+ return true;
388
+ } catch {
389
+ return false;
390
+ }
391
+ })();
392
+ if (skipGit || inRepo) return false;
393
+ try {
394
+ try {
395
+ execFileSync("git", ["init", "-b", "main"], { cwd: targetDir, stdio: "ignore" });
396
+ } catch {
397
+ execFileSync("git", ["init"], { cwd: targetDir, stdio: "ignore" });
398
+ execFileSync("git", ["branch", "-M", "main"], { cwd: targetDir, stdio: "ignore" });
399
+ }
400
+ execFileSync("git", ["add", "-A"], { cwd: targetDir, stdio: "ignore" });
401
+ execFileSync("git", ["commit", "-m", "Initial commit from create-curb.js"], {
402
+ cwd: targetDir,
403
+ stdio: "ignore",
404
+ });
405
+ return true;
406
+ } catch {
407
+ // KEEP-JUSTIFIED: git is optional; a failed init must not fail scaffolding.
408
+ return false;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Animated install progress: spinner + what npm is fetching right now + an
414
+ * elapsed-seconds counter, so a long install is never a silent wait.
415
+ * npm's own progress bar dies when piped, so its http log is streamed
416
+ * instead and the current package name lifted from the fetch URLs.
417
+ */
418
+ function installWithSpinner() {
419
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
420
+ let at = 0;
421
+ const started = Date.now();
422
+ let detail = "resolving packages";
423
+ const elapsed = () => Math.round((Date.now() - started) / 1000);
424
+ const render = () => {
425
+ const line = `${orange(frames[at])} Installing dependencies ${pc.dim(`· ${detail} · ${elapsed()}s`)}`;
426
+ process.stdout.write(`\r\x1b[K${line}`);
427
+ };
428
+ render();
429
+ const timer = setInterval(() => {
430
+ at = (at + 1) % frames.length;
431
+ render();
432
+ }, 120);
433
+
434
+ // Resolves true when npm install exited 0.
435
+ return new Promise((resolveInstall) => {
436
+ const finish = (ok, line, tail) => {
437
+ clearInterval(timer);
438
+ process.stdout.write(`\r\x1b[K${line}\n`);
439
+ if (tail) console.error(tail);
440
+ resolveInstall(ok);
441
+ };
442
+ const npm = npmCommand("npm", ["install", "--loglevel=http", "--no-fund", "--no-audit"]);
443
+ const child = spawn(npm.file, npm.args, { cwd: targetDir, shell: npm.shell });
444
+ const stderrLines = [];
445
+ child.stderr.on("data", (chunk) => {
446
+ const text = chunk.toString();
447
+ for (const rawLine of text.split("\n")) {
448
+ const trimmed = rawLine.trim();
449
+ if (trimmed) stderrLines.push(trimmed);
450
+ }
451
+ if (stderrLines.length > 60) stderrLines.splice(0, stderrLines.length - 60);
452
+ // "npm http fetch GET 200 https://…/react-dom-19.2.7.tgz 84ms" → react-dom-19.2.7
453
+ const fetches = text.match(/https?:\/\/\S+\/([^/\s]+)\.tgz/g);
454
+ const name = fetches?.at(-1)?.match(/\/([^/]+)\.tgz$/)?.[1];
455
+ if (name) detail = name.length > 42 ? `${name.slice(0, 41)}…` : name;
456
+ });
457
+ child.on("error", (error) => {
458
+ finish(
459
+ false,
460
+ `${pc.red("✖")} npm install could not start ${pc.dim(`(${error.message})`)}`,
461
+ `Run "npm install" inside ${targetArg} yourself.`,
462
+ );
463
+ });
464
+ child.on("close", (code) => {
465
+ if (code === 0) {
466
+ finish(true, `${pc.green("✔")} Dependencies installed ${pc.dim(`in ${elapsed()}s`)}`);
467
+ return;
468
+ }
469
+ // The http fetch log is noise in a failure report — show real errors.
470
+ const tail = stderrLines.filter((line) => !line.startsWith("npm http")).slice(-10).join("\n");
471
+ finish(
472
+ false,
473
+ `${pc.red("✖")} npm install failed ${pc.dim(`after ${elapsed()}s`)}`,
474
+ `${tail}\nRun "npm install" inside ${targetArg} to see the full output.`,
475
+ );
476
+ });
477
+ });
478
+ }
479
+
480
+ /** npm install with npm's own full output, for automation (piped stdout: agents, CI). */
481
+ function installPlain() {
482
+ console.log("Installing dependencies…");
483
+ try {
484
+ // Warnings and errors only: no progress chatter, funding or audit summary.
485
+ const npm = npmCommand("npm", ["install", "--loglevel=warn", "--no-fund", "--no-audit"]);
486
+ execFileSync(npm.file, npm.args, { cwd: targetDir, stdio: "inherit", shell: npm.shell });
487
+ return true;
488
+ } catch (error) {
489
+ // npm printed its errors above; this names what failed and where to retry.
490
+ console.error(`${pc.red("✖")} npm install failed (${error.message.split("\n")[0]}). Run "npm install" inside ${targetArg}.`);
491
+ return false;
492
+ }
493
+ }
494
+
495
+ /** Fails the setup when npm skipped a dependency's install script, naming the two commands
496
+ * that fix it: the install exits 0, and the project would fail at its first turn instead. */
497
+ function exitOnBlockedInstallScripts() {
498
+ let blocked;
499
+ try {
500
+ blocked = blockedInstallScripts(targetDir);
501
+ } catch (error) {
502
+ fail(`${pc.red("✖")} Could not list the install scripts npm skipped: ${error.message}\nRun "npm install-scripts ls" inside ${targetArg}.`);
503
+ }
504
+ if (blocked.length === 0) return;
505
+ const [approve, rebuild] = installScriptsFix(blocked);
506
+ fail(
507
+ [
508
+ `${pc.red("✖")} npm skipped the install scripts of ${blocked.join(", ")}, so the agent would fail at its first turn.`,
509
+ `Approve and run them inside ${targetArg}:`,
510
+ ` ${approve}`,
511
+ ` ${rebuild}`,
512
+ ].join("\n"),
513
+ );
514
+ }
515
+
516
+ // The keys the selected providers need that the environment did not hold.
517
+ const missingKeys = requiredKeys.filter((name) => !envValues[name]);
518
+ const otherMissingKeys = missingKeys.filter((name) => name !== "KUGELAUDIO_API_KEY");
519
+ const allKugelAudio = [answers.llm, answers.stt, answers.tts].every((id) => id === "kugelaudio");
520
+ const step = (command, note) => `${orange(command.padEnd(16))}${pc.dim(note)}`;
521
+ const nextSteps = [
522
+ // Scaffolded into the current folder ("."): nothing to change into.
523
+ ...(targetDir === process.cwd() ? [] : [orange(`cd ${targetArg}`)]),
524
+ ...(missingKeys.includes("KUGELAUDIO_API_KEY") ? [step("npx curb login", "sign in in your browser")] : []),
525
+ ...(otherMissingKeys.length > 0 ? [`Add your keys to .env: ${otherMissingKeys.join(", ")}`] : []),
526
+ step("npm run dev", "then open http://127.0.0.1:4000 and talk to your agent"),
527
+ ];
528
+
529
+ console.log(`${pc.green("✔")} ${pc.bold(appName)} created`);
530
+ if (allKugelAudio) {
531
+ console.log(
532
+ pc.dim(" Uses KugelAudio for speech and the language model. You can change providers later in src/agent/agent.tsx."),
533
+ );
534
+ }
535
+ if (gitInit()) console.log(`${pc.green("✔")} Git repository initialized`);
536
+ if (skipInstall) console.log(pc.dim("Skipped npm install (--skip-install)."));
537
+ else {
538
+ // A live terminal gets the spinner; automation (piped stdout: agents, CI) gets npm's
539
+ // own full output instead.
540
+ const installed = process.stdout.isTTY ? await installWithSpinner() : installPlain();
541
+ if (!installed) process.exit(1);
542
+ exitOnBlockedInstallScripts();
543
+ }
544
+ console.log(["", "Next:", ...nextSteps.map((line) => ` ${line}`), ""].join("\n"));
@@ -0,0 +1,39 @@
1
+ // npm 12 runs a dependency's install script only when the project's package.json
2
+ // "allowScripts" approves it, and an install that skipped one still exits 0. The template
3
+ // decides every install script a fresh project meets (it denies the ones it lists, none of
4
+ // which the agent needs); this check turns any other skipped script into a failed setup,
5
+ // instead of an agent that fails at its first turn (a native binding that was never built).
6
+ import { execFileSync } from "node:child_process";
7
+ import { npmCommand } from "./npm-command.js";
8
+
9
+ /** The first npm major that blocks dependency install scripts by default. */
10
+ export const FIRST_BLOCKING_NPM_MAJOR = 12;
11
+
12
+ /** The packages `npm install-scripts ls --json` reports with a pending install script: one
13
+ * that allowScripts neither approves nor denies, so npm skipped it. */
14
+ export function pendingInstallScripts(lsJson) {
15
+ const report = JSON.parse(lsJson);
16
+ if (!report || !Array.isArray(report.allowScripts)) {
17
+ throw new Error(`npm install-scripts ls --json printed no "allowScripts" list: ${lsJson.trim().slice(0, 200)}`);
18
+ }
19
+ return report.allowScripts
20
+ .filter((entry) => entry.changes.some((change) => change.change === "pending"))
21
+ .map((entry) => entry.name);
22
+ }
23
+
24
+ /** The two commands, run inside the project, that approve the skipped scripts and run them. */
25
+ export function installScriptsFix(names) {
26
+ return [`npm install-scripts approve ${names.join(" ")}`, `npm rebuild ${names.join(" ")}`];
27
+ }
28
+
29
+ /** The packages whose install scripts npm skipped in `dir`. None before npm 12, which runs
30
+ * every dependency's install script. */
31
+ export function blockedInstallScripts(dir) {
32
+ const npm = (...args) => {
33
+ const command = npmCommand("npm", args);
34
+ return execFileSync(command.file, command.args, { cwd: dir, encoding: "utf8", shell: command.shell });
35
+ };
36
+ const npmVersion = npm("--version").trim();
37
+ if (Number(npmVersion.split(".")[0]) < FIRST_BLOCKING_NPM_MAJOR) return [];
38
+ return pendingInstallScripts(npm("install-scripts", "ls", "--json"));
39
+ }
@@ -0,0 +1,48 @@
1
+ // Refuses a Node.js older than this package's `engines.node`, by name, before
2
+ // anything version-sensitive loads. `engines` alone only makes npm warn, and an
3
+ // old Node then fails much later with an unrelated syntax or API error.
4
+ // `packages/framework/bin/node-version.js` is the same check for the `curb`
5
+ // CLI; the scaffolder ships as its own package, so it carries its own copy.
6
+ import { readFileSync } from "node:fs";
7
+
8
+ /** The `engines.node` floor as `[major, minor, patch]`. Only `>=X.Y.Z` is accepted, so the
9
+ * range stays one number that this check and npm read the same way. */
10
+ export function nodeFloor(range) {
11
+ const match = /^>=(\d+)\.(\d+)\.(\d+)$/.exec(range);
12
+ if (!match) {
13
+ throw new Error(`package.json "engines.node" must read ">=X.Y.Z", got ${JSON.stringify(range)}`);
14
+ }
15
+ return match.slice(1).map(Number);
16
+ }
17
+
18
+ /** The refusal for `version` (as in `process.versions.node`), or null when it is supported. */
19
+ export function unsupportedNodeMessage(packageName, range, version) {
20
+ const floor = nodeFloor(range);
21
+ const running = version.split(".").map(Number);
22
+ for (let index = 0; index < 3; index += 1) {
23
+ if (running[index] > floor[index]) return null;
24
+ if (running[index] < floor[index]) {
25
+ return (
26
+ `UnsupportedNodeVersion: ${packageName} needs Node.js ${range}, ` +
27
+ `but this is Node.js ${version}. Install Node.js ${floor.join(".")} or newer ` +
28
+ "(https://nodejs.org) and run the command again."
29
+ );
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /** Exits 1 with the refusal when the running Node is older than `engines.node` in the
36
+ * package.json at `packageJsonUrl`. */
37
+ export function exitOnUnsupportedNode(packageJsonUrl) {
38
+ const manifest = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
39
+ const message = unsupportedNodeMessage(
40
+ manifest.name,
41
+ manifest.engines && manifest.engines.node,
42
+ process.versions.node,
43
+ );
44
+ if (message) {
45
+ console.error(message);
46
+ process.exit(1);
47
+ }
48
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * How to start npm or npx. On Windows they are `.cmd` shims, which Node starts only
3
+ * through a shell, so the arguments go into one quoted command line there.
4
+ */
5
+ export function npmCommand(name, args, platform = process.platform) {
6
+ if (platform !== "win32") return { file: name, args, shell: false };
7
+ const unquotable = args.find((arg) => arg.includes('"'));
8
+ if (unquotable) throw new Error(`Cannot pass ${unquotable} to ${name}: Windows allows no double quote in it.`);
9
+ return { file: [name, ...args.map((arg) => `"${arg}"`)].join(" "), args: [], shell: true };
10
+ }
@@ -0,0 +1,5 @@
1
+ /** The scaffold's arguments: `my-agent` first when they do not start with a project folder. */
2
+ export function scaffoldArgs(args) {
3
+ if (args.includes("--help") || args.includes("-h")) return args;
4
+ return args.length > 0 && !args[0].startsWith("-") ? args : ["my-agent", ...args];
5
+ }