@niroai/niro 0.9.0 → 0.10.0

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,567 @@
1
+ /**
2
+ * `niro start` — the one command a new user runs.
3
+ *
4
+ * A thin ORCHESTRATOR over the commands that already exist. It deliberately owns no
5
+ * onboarding logic of its own: a second implementation of project creation or env
6
+ * scanning would drift from init.js within a release. Every step below either calls an
7
+ * existing command module or asks a question.
8
+ *
9
+ * 0. Beta gate — access code, asked BEFORE anything is installed or contacted.
10
+ * 1. Bootstrap — install globally and re-exec when running from the npx cache.
11
+ * 2. Sign in — login.js (device flow), skipped when credentials exist.
12
+ * 3. Choose folder — show what was detected, confirm, re-ask on a refusal.
13
+ * 4. Onboard — init.js, unchanged.
14
+ * 5. Connect — mcp.js install(), skipped when this machine already did it.
15
+ *
16
+ * Why step 0 precedes step 1: the beta needs an access code and a work email, so
17
+ * someone without a code cannot get an account. Asking first costs one prompt and
18
+ * saves them installing software they cannot use. Nothing touches their machine
19
+ * before they answer.
20
+ *
21
+ * Why step 1 exists at all: service.js registers the watch daemon with PM2 using
22
+ * `process.argv[1]`. Under npx that path lives in ~/.npm/_npx/<hash>/, which npm
23
+ * garbage-collects on its own schedule, so a watcher registered from an npx run would
24
+ * look healthy and then stop syncing days later with no error anywhere. npx is the
25
+ * bootstrapper, not the way to run Niro.
26
+ */
27
+ const path = require("path");
28
+ const fs = require("fs");
29
+ // Required as a namespace, not destructured, so tests can substitute spawnSync
30
+ // without shelling out to npm or re-execing a real binary.
31
+ const childProcess = require("child_process");
32
+
33
+ const color = require("../lib/color");
34
+ const prompt = require("../lib/prompt");
35
+ const credentials = require("../lib/credentials");
36
+ const config = require("../lib/config");
37
+ const git = require("../lib/git");
38
+ const onboardTarget = require("../lib/onboardTarget");
39
+
40
+ const step = (n) => color.bold(color.cyan(n));
41
+ const ok = (s) => color.green(s);
42
+ const warn = (s) => color.yellow(s);
43
+ const fail = (s) => color.red(s);
44
+
45
+ const TOTAL_STEPS = 5;
46
+ const PACKAGE_NAME = "@niroai/niro";
47
+
48
+ /** Offered in the welcome screen and again at the end. Not shown to returning users. */
49
+ const BOOK_A_CALL = "https://tidycal.com/nirajaswani/45-minutes-meeting-mpy606v";
50
+
51
+ /**
52
+ * Private-beta copy. There is no endpoint that reports whether the gate is on, and
53
+ * adding one to serve a first prompt is not worth the round-trip, so this is a local
54
+ * constant. DELETE THIS BLOCK (and betaGate below) when the beta ends — see
55
+ * docs/usecases/first-run-wizard.md.
56
+ */
57
+ const BETA = {
58
+ on: true,
59
+ // The marketing site, NOT the app: niro.ai, not the niroai.dev hosts everything else in
60
+ // this CLI talks to. Confirmed by the user 2026-08-27 — it cannot be derived from anything
61
+ // in this repo, so it must not be guessed at from the backend hostnames.
62
+ requestUrl: "https://www.niro.ai",
63
+ requestAction: '"Request Access"', // the button on that home page
64
+ };
65
+
66
+ /** Set on the re-exec so the installed copy can never bootstrap a second time. */
67
+ const BOOTSTRAP_ENV = "NIRO_START_BOOTSTRAPPED";
68
+
69
+ async function start(pathArg, opts = {}) {
70
+ if (prompt.isNonInteractive()) {
71
+ console.error(fail("`niro start` is interactive and there is no terminal to prompt on."));
72
+ console.error(" For scripts and agents use the individual commands, which take flags:");
73
+ console.error(` ${color.cyan("niro login --api-key")}`);
74
+ console.error(` ${color.cyan("niro init --defaults")}`);
75
+ console.error(` ${color.cyan("niro mcp install --client <id>")}`);
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+
80
+ // One decision, read once and used everywhere the wizard treats a newcomer differently:
81
+ // the banner, the welcome screen, the what-is-installed note, the beta gate and the
82
+ // booking offer. Separate checks would eventually disagree with each other.
83
+ const firstRun = !hasCredentials();
84
+
85
+ // True in the process step 1 handed over to. That process has no credentials yet, so it
86
+ // looks like a first run and would replay the welcome screen and ask for the access code a
87
+ // SECOND time. The parent already showed both and already got an answer; repeating them
88
+ // makes the install look like a restart. Observed live: the whole opening printed twice.
89
+ const handedOver = !!process.env[BOOTSTRAP_ENV];
90
+
91
+ console.log(`\n ${color.bold("Niro")} ${color.dim("·")} ${firstRun ? "first-time setup" : "set up another repo"}\n`);
92
+
93
+ if (firstRun && !handedOver) printWelcome();
94
+
95
+ // 0. Beta gate. Skipped for anyone already holding credentials: they plainly have an
96
+ // account, and asking again would be theatre. Also skipped after a hand-over, where it
97
+ // was answered a moment ago in the parent.
98
+ if (!handedOver && !(await betaGate())) return;
99
+
100
+ // 1. Bootstrap. Returns true when it re-execed, meaning this process is done.
101
+ if (await bootstrapIfNpx(pathArg, firstRun)) return;
102
+
103
+ // 2. Sign in.
104
+ if (!(await ensureSignedIn())) return;
105
+
106
+ // 3. Choose the folder.
107
+ const target = await chooseFolder(pathArg);
108
+ if (!target) return;
109
+
110
+ // 4. Onboard. init re-validates the folder itself; it stays the authority.
111
+ console.log(`\n ${step(`4/${TOTAL_STEPS}`)} Onboarding and building the index\n`);
112
+ const initCmd = require("./init");
113
+ // Everything init prints belongs under this step, including what its own helpers print.
114
+ const { withIndent } = require("../lib/nestedOutput");
115
+ await withIndent(4, () => initCmd.init(target, { nested: true }));
116
+ if (process.exitCode) return; // init already explained why
117
+
118
+ // 5. Connect an assistant.
119
+ await connectAssistant();
120
+
121
+ if (firstRun) await offerMeasurement();
122
+
123
+ printNextSteps(firstRun);
124
+ }
125
+
126
+ /**
127
+ * The opening screen: what this thing is for, before asking anyone to install it.
128
+ *
129
+ * Four lines, not a tour. Someone who has just typed an unfamiliar command deserves to know
130
+ * what they are about to spend five minutes on, and someone evaluating deserves to know a
131
+ * person is available before they invest that time — which is why the call is offered here
132
+ * and not only at the end.
133
+ */
134
+ function printWelcome() {
135
+ console.log(" Niro maps how your code actually fits together, so your AI");
136
+ console.log(" assistant answers from that map instead of guessing.\n");
137
+ // "in", not "with": the point is that there is no new tool and no new window, you ask
138
+ // inside the thing already open. "it" is dropped because it read ambiguously against
139
+ // "the assistant" — and without it the line fits on one.
140
+ console.log(` ${color.cyan("·")} Ask anything about your code, in the assistant you already use`);
141
+ console.log(` ${color.cyan("·")} See what breaks before you change it, across services`);
142
+ console.log(` ${color.cyan("·")} Get a standing review of the issues already in your code`);
143
+ console.log(` ${color.cyan("·")} Find bugs' root causes from your alerts`);
144
+ console.log(` ${color.cyan("·")} Measure it: run half your sessions without Niro and compare\n`);
145
+ console.log(" Setting up takes about five minutes.");
146
+ console.log(" Prefer a walkthrough first? 45 minutes with us:");
147
+ console.log(` ${color.cyan(BOOK_A_CALL)}\n`);
148
+ }
149
+
150
+ /**
151
+ * Offer the A/B measurement window, once, at the end.
152
+ *
153
+ * Not in the opening, even though the capability is claimed there: enabling needs a signed-in
154
+ * account, and asking someone to measure a thing they have not yet seen work is the wrong
155
+ * order. First run only, and it defaults to NO — this is a measurement option, not something
156
+ * to be nudged into. A returning user finds it in the next-steps list instead.
157
+ *
158
+ * Never fails the wizard. The repo IS indexed by this point; a measurement window is a nicety
159
+ * and a network hiccup here must not read as a broken setup.
160
+ */
161
+ async function offerMeasurement() {
162
+ const abCmd = require("./ab");
163
+ console.log("\n One more thing, entirely optional.\n");
164
+ console.log(` Niro can run ${color.bold("off")} for half your sessions for the next`);
165
+ console.log(` ${abCmd.DEFAULT_WINDOW_DAYS} days, so you can see the difference rather than take`);
166
+ console.log(" our word for it. It applies to your whole account, and");
167
+ console.log(` ${color.cyan("niro ab disable")} stops it at any time.\n`);
168
+
169
+ if (!(await prompt.confirm(" Turn on A/B measurement?", false))) {
170
+ console.log(`\n Left off. Turn it on later with ${color.cyan("niro ab enable")}.`);
171
+ return;
172
+ }
173
+ try {
174
+ await abCmd.enable({ yes: true });
175
+ } catch (err) {
176
+ console.log(`\n ${warn("⚠")} Could not start the measurement window.`);
177
+ console.log(` Your repo is indexed and working. Try ${color.cyan("niro ab enable")} later.`);
178
+ console.log(` (${err && err.message ? err.message : err})`);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * What to do now that a repo is indexed.
184
+ *
185
+ * Ordered by when they will need it, not by how impressive it is: one question they can paste
186
+ * straight away, then the three things they will reach for over the next few days. The web app
187
+ * line is where code review and the graph views get named, because those are things you look
188
+ * at rather than ask for.
189
+ *
190
+ * The web address is whatever the server told us at login, never a constant: a self-hosted
191
+ * install has its own, and a wrong link here is worse than none. Omitted when unknown, which
192
+ * is the case for anyone who logged in before the CLI started recording it.
193
+ */
194
+ function printNextSteps(firstRun) {
195
+ const appUrl = config.load().appUrl;
196
+
197
+ console.log(`\n ${ok("✓ You are set up.")}`);
198
+ console.log("\n Ask this in the assistant you just connected, from this folder:\n");
199
+ console.log(` ${color.bold('"What are the entry points into this repo, and what does each one do?"')}`);
200
+
201
+ console.log("\n When you need them:\n");
202
+ if (appUrl) {
203
+ console.log(` ${color.cyan(appUrl)}`);
204
+ console.log(" the graph, impact views and your code review, in the browser");
205
+ }
206
+ console.log(` ${color.cyan("niro start")} in another repo`);
207
+ console.log(" cross-service answers need more than one repo indexed");
208
+ console.log(` ${color.cyan("niro new-temp-project")}`);
209
+ console.log(" run it once on a branch Niro has not indexed, so answers match your work");
210
+ console.log(` ${color.cyan("niro status")}`);
211
+ console.log(" check the index is current");
212
+ console.log(` ${color.cyan("niro ab")}`);
213
+ console.log(" measure what Niro changes, by running it off half the time");
214
+
215
+ if (firstRun) {
216
+ console.log(`\n Questions, or want us to walk through your codebase with you?`);
217
+ console.log(` 45 minutes with us: ${color.cyan(BOOK_A_CALL)}`);
218
+ }
219
+ console.log("");
220
+ }
221
+
222
+ /**
223
+ * What one `npm install` actually puts on the machine.
224
+ *
225
+ * Printed at the install step, because that is the moment software lands and the honest time
226
+ * to say what it is. Three things arrive from one package and nothing else in the flow says
227
+ * so: the CLI is visible (they just typed it), but the MCP server and the skills are not, and
228
+ * a developer who does not know the skills exist will never think to ask for a rebuild in
229
+ * plain English.
230
+ *
231
+ * First run only, like the welcome screen. Someone setting up their fifth repo knows.
232
+ */
233
+ function printWhatIsInstalled() {
234
+ console.log(" One package, three parts:\n");
235
+ console.log(` ${color.bold("niro")} the command you are running now: onboard a repo,`);
236
+ console.log(" check status, rebuild, manage projects");
237
+ console.log(` ${color.bold("MCP server")} how your AI assistant reaches Niro's map of your`);
238
+ console.log(" code, so it answers from that instead of guessing");
239
+ console.log(` ${color.bold("two skills")} so you can say \"index this repo\" or \"what breaks`);
240
+ console.log(" if I change this\" instead of remembering commands");
241
+ console.log("");
242
+ }
243
+
244
+ /**
245
+ * Step 0. Returns false when the user has no access code, having changed nothing.
246
+ *
247
+ * Deliberately NOT a server capability probe: see BETA above.
248
+ */
249
+ async function betaGate() {
250
+ if (!BETA.on) return true;
251
+ if (hasCredentials()) return true; // already has an account on this machine
252
+
253
+ console.log(" Niro is in private beta. To create an account you need:");
254
+ console.log(" · an access code (the browser signup form asks for it by that name)");
255
+ console.log(" · a work email address (personal addresses are not accepted yet)\n");
256
+ console.log(` No access code? Click ${BETA.requestAction} at ${color.cyan(BETA.requestUrl)}\n`);
257
+
258
+ const hasCode = await prompt.confirm(" Do you have an access code?", true);
259
+ if (hasCode) return true;
260
+
261
+ console.log(`\n No problem. Open ${color.cyan(BETA.requestUrl)} and click ${BETA.requestAction},`);
262
+ console.log(" then run this again when your code arrives:\n");
263
+ console.log(` ${color.cyan(`npx ${PACKAGE_NAME} start`)}\n`);
264
+ console.log(" Nothing was installed or changed on this machine.\n");
265
+ return false;
266
+ }
267
+
268
+ /**
269
+ * The same condition credentials.requireCreds() enforces, and deliberately not a second
270
+ * definition of it: any usable credential counts, because the request layer refreshes an
271
+ * expired JWT from the durable cliToken on its own.
272
+ */
273
+ function hasCredentials() {
274
+ const c = credentials.load();
275
+ return !!(c && (c.token || c.cliToken || c.niroApiKey));
276
+ }
277
+
278
+ /** True when this process is running out of npm's throwaway npx cache. */
279
+ function runningFromNpxCache(argv1 = process.argv[1]) {
280
+ return /[\\/]_npx[\\/]/.test(argv1 || "");
281
+ }
282
+
283
+ /**
284
+ * The absolute path of a PERMANENTLY installed `niro`, or null.
285
+ *
286
+ * Deliberately not "is niro on PATH". Under npx, PATH begins with npx's own throwaway cache,
287
+ * so a PATH probe finds THIS process's binary and concludes a global install already exists:
288
+ * the install is skipped, and the hand-over re-execs the same temporary copy. Which is the
289
+ * whole bug step 1 exists to prevent, plus a wizard that restarts from its first screen.
290
+ * Observed for real: `command -v niro` inside npx returns
291
+ * ~/.npm/_npx/<hash>/node_modules/.bin/niro.
292
+ *
293
+ * npm's own global prefix is the only answer that cannot be the cache.
294
+ */
295
+ function globalNiroPath() {
296
+ const r = childProcess.spawnSync("npm", ["prefix", "-g"], { shell: true, encoding: "utf8" });
297
+ if (r.status !== 0) return null;
298
+ const prefix = String(r.stdout || "").trim();
299
+ if (!prefix) return null;
300
+ const bin = process.platform === "win32"
301
+ ? path.join(prefix, "niro.cmd")
302
+ : path.join(prefix, "bin", "niro");
303
+ return fs.existsSync(bin) ? bin : null;
304
+ }
305
+
306
+ /** True when the installed copy at `bin` is at least as new as this one. */
307
+ function isCurrent(bin) {
308
+ const r = childProcess.spawnSync(bin, ["--version"], { encoding: "utf8" });
309
+ if (r.status !== 0) return false;
310
+ return !isOlder(String(r.stdout || "").trim(), require("../../package.json").version);
311
+ }
312
+
313
+ /** True when semver string `a` is strictly older than `b`. Unparseable counts as older. */
314
+ function isOlder(a, b) {
315
+ const parts = (v) => {
316
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(v || "");
317
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
318
+ };
319
+ const x = parts(a);
320
+ const y = parts(b);
321
+ if (!x || !y) return true;
322
+ for (let i = 0; i < 3; i++) {
323
+ if (x[i] !== y[i]) return x[i] < y[i];
324
+ }
325
+ return false;
326
+ }
327
+
328
+ /**
329
+ * Step 1. Install globally and hand over, when and only when this copy came from npx.
330
+ *
331
+ * Returns true if it re-execed (the caller must stop). The global install is NOT
332
+ * prompted for: the alternative is a question whose wrong answer produces a silently
333
+ * dead watch daemon days later.
334
+ */
335
+ async function bootstrapIfNpx(pathArg, firstRun = false) {
336
+ const handedOver = !!process.env[BOOTSTRAP_ENV];
337
+ if (handedOver || !runningFromNpxCache()) {
338
+ // Nothing to do: either this IS the handed-over child, or niro was installed
339
+ // properly in the first place. Still announced, so the wizard shows all five steps
340
+ // and reads as a finite process rather than an open-ended one.
341
+ console.log(` ${step(`1/${TOTAL_STEPS}`)} ${ok("✓")} niro is installed.\n`);
342
+ if (firstRun && !handedOver) printWhatIsInstalled();
343
+ return false;
344
+ }
345
+
346
+ console.log(` ${step(`1/${TOTAL_STEPS}`)} Installing niro\n`);
347
+ if (firstRun) printWhatIsInstalled();
348
+ console.log(" Background sync has to outlive npx's temporary cache, so Niro");
349
+ console.log(" installs itself properly first.\n");
350
+
351
+ let bin = globalNiroPath();
352
+ if (bin && isCurrent(bin)) {
353
+ console.log(` ${ok("✓")} Already installed at ${bin}.\n`);
354
+ } else {
355
+ const r = childProcess.spawnSync("npm", ["install", "-g", PACKAGE_NAME], { shell: true, stdio: "inherit" });
356
+ bin = r.status === 0 ? globalNiroPath() : null;
357
+ // Only claim success once the binary is actually THERE. The old wording announced an
358
+ // install that had been skipped, which is how an empty global folder came to look fine.
359
+ if (!bin) {
360
+ console.error(fail("\n ✗ Global install failed."));
361
+ console.error(` Install it yourself, then run ${color.cyan("niro start")}:`);
362
+ console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}`)}`);
363
+ process.exitCode = 1;
364
+ return true;
365
+ }
366
+ console.log(` ${ok("✓")} ${PACKAGE_NAME} installed at ${bin}.\n`);
367
+ }
368
+
369
+ const args = ["start"];
370
+ if (pathArg) args.push(pathArg);
371
+ // The resolved absolute path, never the bare name: under npx the name resolves back to the
372
+ // cache we are trying to escape.
373
+ const handover = childProcess.spawnSync(bin, args, {
374
+ stdio: "inherit",
375
+ env: { ...process.env, [BOOTSTRAP_ENV]: "1" },
376
+ });
377
+ process.exitCode = handover.status === null ? 1 : handover.status;
378
+ return true;
379
+ }
380
+
381
+ /** Step 2. Returns false only when login failed. */
382
+ async function ensureSignedIn() {
383
+ if (hasCredentials()) {
384
+ const creds = credentials.load();
385
+ console.log(` ${step(`2/${TOTAL_STEPS}`)} ${ok("✓")} Signed in${creds.email ? ` as ${color.bold(creds.email)}` : ""}.\n`);
386
+ return true;
387
+ }
388
+
389
+ console.log(` ${step(`2/${TOTAL_STEPS}`)} Sign in\n`);
390
+ console.log(" Opening your browser. Sign in there, or create your account");
391
+ console.log(" with your access code.\n");
392
+ console.log(" Creating an account? You will get a verification email. Take");
393
+ console.log(" your time. This keeps waiting.\n");
394
+
395
+ const loginCmd = require("./login");
396
+ await loginCmd.login({});
397
+ if (!hasCredentials()) {
398
+ console.error(fail("\n ✗ Not signed in, so there is nothing to onboard to."));
399
+ console.error(` Run ${color.cyan("niro start")} again when you are ready.`);
400
+ process.exitCode = 1;
401
+ return false;
402
+ }
403
+ return true;
404
+ }
405
+
406
+ /**
407
+ * Step 3. Confirm what is about to be uploaded, and keep asking on a refusal.
408
+ *
409
+ * The refusal RULES are not re-implemented here: protectedRootReason and
410
+ * findProjectIndicators are the same functions init.js validates with, so the preview
411
+ * can never disagree with the gate. init.js re-runs the authoritative check.
412
+ *
413
+ * Returns the chosen absolute path, or null if the user gave up.
414
+ */
415
+ async function chooseFolder(pathArg) {
416
+ let candidate = path.resolve(pathArg || process.cwd());
417
+
418
+ for (;;) {
419
+ console.log(` ${step(`3/${TOTAL_STEPS}`)} Choose what to index\n`);
420
+
421
+ const problem = describeProblem(candidate);
422
+ if (problem) {
423
+ console.log(` ${fail("✗")} ${problem.headline}\n`);
424
+ for (const line of problem.detail) console.log(` ${line}`);
425
+ console.log("");
426
+ const answer = await prompt.ask(" Which folder do you want to index? (blank to give up)");
427
+ if (!answer || !answer.trim()) {
428
+ console.log(`\n Stopped. Nothing was changed. Run ${color.cyan("niro start")} from a project folder.\n`);
429
+ return null;
430
+ }
431
+ candidate = path.resolve(expandHome(answer.trim()));
432
+ console.log("");
433
+ continue;
434
+ }
435
+
436
+ printPreview(candidate);
437
+ if (await prompt.confirm(" Index this folder?", true)) return candidate;
438
+
439
+ const answer = await prompt.ask(" Which folder instead? (blank to give up)");
440
+ if (!answer || !answer.trim()) {
441
+ console.log(`\n Stopped. Nothing was changed.\n`);
442
+ return null;
443
+ }
444
+ candidate = path.resolve(expandHome(answer.trim()));
445
+ console.log("");
446
+ }
447
+ }
448
+
449
+ /** A leading ~ is expanded here because a prompt answer never goes through a shell. */
450
+ function expandHome(p) {
451
+ if (p === "~") return require("os").homedir();
452
+ if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(require("os").homedir(), p.slice(2));
453
+ return p;
454
+ }
455
+
456
+ /**
457
+ * Why this folder cannot be onboarded, in the wizard's voice, or null if it can.
458
+ * Mirrors ensureOnboardable's two rules by calling the same functions it calls.
459
+ */
460
+ function describeProblem(dir) {
461
+ if (!fs.existsSync(dir)) {
462
+ return { headline: `${dir} does not exist.`, detail: ["Check the path and try again."] };
463
+ }
464
+ if (!fs.statSync(dir).isDirectory()) {
465
+ return { headline: `${dir} is a file, not a folder.`, detail: [] };
466
+ }
467
+
468
+ const reason = onboardTarget.protectedRootReason(dir);
469
+ if (reason) {
470
+ return {
471
+ headline: `${dir} is ${reason}. Niro will not index it.`,
472
+ // Says only what is true. folderSync never descends into a dot directory
473
+ // (folderSync.js walkDir) and only uploads source, build and committed config files
474
+ // (isUploadableName), so keys, documents and photos are never eligible and claiming
475
+ // otherwise is scaremongering. What IS lost is .gitignore: it is only honoured when
476
+ // the onboarded folder is itself a git work tree, and a home folder is not one, so
477
+ // each nested repo's ignored files stop being protected.
478
+ detail: [
479
+ "It would upload source from every repository under it, and lose the",
480
+ ".gitignore protection each one has, because the folder you point at",
481
+ "has to be the git repository for that to apply.",
482
+ ],
483
+ };
484
+ }
485
+
486
+ const { indicators } = onboardTarget.findProjectIndicators(dir);
487
+ if (indicators.size === 0) {
488
+ return {
489
+ headline: `No project found in ${dir}.`,
490
+ detail: [
491
+ "Niro looks for a build file (package.json, pom.xml, go.mod, ...) in",
492
+ "this folder or just below it. None found.",
493
+ `If you really mean this folder, use ${color.cyan("niro init --force")} instead.`,
494
+ ],
495
+ };
496
+ }
497
+ return null;
498
+ }
499
+
500
+ /**
501
+ * The screen that prevents the accident: what is about to be uploaded, before it is.
502
+ *
503
+ * Deliberately cheap — no full tree walk. The project scan has already run for the
504
+ * refusal check and git.inspect reads two small files, so this adds nothing the wizard
505
+ * was not doing anyway. init.js does the real manifest walk a moment later.
506
+ */
507
+ function printPreview(dir) {
508
+ const { roots, indicators } = onboardTarget.findProjectIndicators(dir);
509
+ const info = git.inspect(dir);
510
+
511
+ console.log(` Folder: ${color.bold(dir)}`);
512
+ console.log(` Detected: ${onboardTarget.describeIndicators(indicators)}`);
513
+ console.log(` Projects: ${roots} module${roots === 1 ? "" : "s"}`);
514
+ if (info.repoRoot) {
515
+ console.log(` Git: branch ${info.branch || "(detached)"}${info.originUrl ? `, ${info.originUrl}` : ""}`);
516
+ } else {
517
+ console.log(` Git: ${color.dim("not a git repo, which is fine; it uploads as-is")}`);
518
+ }
519
+ console.log("");
520
+ }
521
+
522
+ /**
523
+ * Step 5. `mcp install` prompts for the client itself and resolves the key from the
524
+ * login session, so there is nothing to pass it.
525
+ *
526
+ * "Already connected" is a wizard-local flag rather than a probe of 14 clients' config
527
+ * files: those paths live inside src/mcp/setup.js and are not exported, and a wrong
528
+ * guess here would silently skip the step that makes Niro usable. The flag only ever
529
+ * records what THIS wizard did, and re-running mcp install is harmless anyway.
530
+ */
531
+ async function connectAssistant() {
532
+ const saved = config.load();
533
+ if (saved.mcpConnectedAt) {
534
+ console.log(`\n ${step(`5/${TOTAL_STEPS}`)} ${ok("✓")} Assistant already connected on this machine.`);
535
+ console.log(` Connect another with ${color.cyan("niro mcp install")}.`);
536
+ return;
537
+ }
538
+
539
+ console.log(`\n ${step(`5/${TOTAL_STEPS}`)} Connect your AI assistant\n`);
540
+ const mcpCmd = require("./mcp");
541
+ try {
542
+ await mcpCmd.install();
543
+ config.save({ mcpConnectedAt: new Date().toISOString() });
544
+ } catch (err) {
545
+ // Never fail the whole wizard here: the repo IS indexed by this point, and the
546
+ // user can retry a single command.
547
+ console.log(`\n ${warn("⚠")} Could not configure an assistant automatically.`);
548
+ console.log(` Run ${color.cyan("niro mcp install")} when you are ready.`);
549
+ console.log(` (${err && err.message ? err.message : err})`);
550
+ }
551
+ }
552
+
553
+ module.exports = {
554
+ start,
555
+ // exported for tests
556
+ hasCredentials,
557
+ printWelcome,
558
+ printWhatIsInstalled,
559
+ offerMeasurement,
560
+ printNextSteps,
561
+ BOOK_A_CALL,
562
+ runningFromNpxCache,
563
+ isOlder,
564
+ describeProblem,
565
+ expandHome,
566
+ BOOTSTRAP_ENV,
567
+ };
@@ -73,9 +73,9 @@ async function takeover(pathArg, opts = {}) {
73
73
  const list = ctx.projects.map((p) => ` ${p.project_id} (${p.alias})`).join("\n");
74
74
  throw new Error(`Multiple projects match this repo — pass --project:\n${list}`);
75
75
  }
76
- console.log("\n Multiple projects match this repo:");
76
+ console.log("\n Multiple projects match this repo:");
77
77
  ctx.projects.forEach((p, i) => console.log(` ${i + 1}. ${p.alias || "(no alias)"} ${color.dim(p.project_id)}`));
78
- const answer = await prompt.ask(` Take over which one? [1-${ctx.projects.length}]`, "1");
78
+ const answer = await prompt.ask(` Take over which one? [1-${ctx.projects.length}]`, "1");
79
79
  const idx = parseInt(answer, 10) - 1;
80
80
  if (isNaN(idx) || idx < 0 || idx >= ctx.projects.length) {
81
81
  throw new Error(`Invalid choice: ${answer}`);
@@ -121,11 +121,11 @@ async function takeover(pathArg, opts = {}) {
121
121
  && path.resolve(e.folderPath) !== here);
122
122
  if (holder) {
123
123
  const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
124
- console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
125
- console.log(warn(" held by another folder:"));
124
+ console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
125
+ console.log(warn(" held by another folder:"));
126
126
  console.log(` ${holder.folderPath}${onBranch}`);
127
- console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
128
- console.log(color.dim(" project until `niro new-temp-project` is run there again."));
127
+ console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
128
+ console.log(color.dim(" project until `niro new-temp-project` is run there again."));
129
129
  if (prompt.isNonInteractive()) {
130
130
  // Refuse rather than hang, and rather than proceed. Proceeding is what caused the problem; hanging
131
131
  // would break any agent or script that runs this unattended. The way forward is two explicit
@@ -136,13 +136,13 @@ async function takeover(pathArg, opts = {}) {
136
136
  + holder.folderPath + (holder.branch ? ` on branch ${holder.branch}` : "")
137
137
  + ". Run `niro discard-temp-project` in that folder first, then re-run this here.");
138
138
  }
139
- if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
139
+ if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
140
140
  throw new Error("Cancelled — the temporary project stays where it is.");
141
141
  }
142
142
  }
143
143
 
144
- console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
145
- console.log(color.dim(" Your original project is not changed."));
144
+ console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
145
+ console.log(color.dim(" Your original project is not changed."));
146
146
 
147
147
  // 2. Create-or-extend server-side (ADR-016). If you already have a temporary project of this project,
148
148
  // the backend EXTENDS it — adds this repo to the same copy — instead of minting a second one, so
@@ -163,16 +163,16 @@ async function takeover(pathArg, opts = {}) {
163
163
  // route; its absence means a version mismatch the user must hear about NOW, not discover via wrong answers.
164
164
  const serverRoutes = outcome.routing_supported === true || outcome.routingSupported === true;
165
165
  if (!serverRoutes) {
166
- console.log(warn("\n ⚠ Your backend did NOT confirm automatic routing (pre-ADR-018 server?)."));
166
+ console.log(warn("\n ⚠ Your backend did NOT confirm automatic routing (pre-ADR-018 server?)."));
167
167
  console.log(warn(" This CLI no longer re-pins .niro-project, so against this backend Niro's answers"));
168
168
  console.log(warn(" will KEEP coming from the original project — NOT this temporary project."));
169
169
  console.log(warn(" Fix: upgrade the backend (ai-assistant + ai-orchestrator), or use the previous CLI."));
170
170
  }
171
171
  if (wasExtended) {
172
- console.log(` ${step("●")} Added this repo to your temporary project: ${color.bold(outcome.alias || newProjectId)} ` +
172
+ console.log(` ${step("●")} Added this repo to your temporary project: ${color.bold(outcome.alias || newProjectId)} ` +
173
173
  color.dim("(existing copy extended — your other repos are untouched)"));
174
174
  } else {
175
- console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
175
+ console.log(` ${step("●")} Temporary project created: ${color.bold(outcome.alias || newProjectId)} ` +
176
176
  color.dim(`(${outcome.nodes_copied ?? "?"} nodes copied, no re-index needed)`));
177
177
  }
178
178
 
@@ -190,7 +190,7 @@ async function takeover(pathArg, opts = {}) {
190
190
  folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(warn(m)));
191
191
  const sizes = {};
192
192
  for (const m of manifest) sizes[m.path] = m.size_bytes;
193
- console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
193
+ console.log(` ${step("●")} Uploading working tree: ${includedPaths.length} files ` +
194
194
  `(branch ${color.cyan(branch || "detached HEAD")})...`);
195
195
  // Capture the uploaded hashes: takeover registers its watchState entry AFTER this upload (below), so
196
196
  // doInitialSync's own watchState.update no-ops (no entry yet). Seeding these into the final set means
@@ -203,9 +203,9 @@ async function takeover(pathArg, opts = {}) {
203
203
  await api.post(`/api/graph/${encodeURIComponent(newProjectId)}/build`, {}, {
204
204
  query: { force_full_rebuild: "false" },
205
205
  });
206
- console.log(` ${step("●")} Incremental index of your working tree triggered.`);
206
+ console.log(` ${step("●")} Incremental index of your working tree triggered.`);
207
207
  } catch (buildErr) {
208
- console.log(warn(` ⚠ Could not trigger the index build automatically (${buildErr.message}). ` +
208
+ console.log(warn(` ⚠ Could not trigger the index build automatically (${buildErr.message}). ` +
209
209
  "Run `niro build` to index your uploaded tree."));
210
210
  }
211
211
 
@@ -265,11 +265,11 @@ async function takeover(pathArg, opts = {}) {
265
265
  }
266
266
  }
267
267
 
268
- console.log(`\n ${ok("✓")} Niro now answers from your temporary project whenever you are on this branch`);
268
+ console.log(`\n ${ok("✓")} Niro now answers from your temporary project whenever you are on this branch`);
269
269
  console.log(color.dim(" (routing is automatic per branch — your .niro-project file was NOT changed)."));
270
- console.log(` ${ok("✓")} Local edits stream in live while ${color.bold("niro watch")} is running.`);
271
- console.log(color.dim("\n When your branch is merged: `niro discard-temp-project` deletes the temporary project;"));
272
- console.log(color.dim(" routing falls back to the original project by itself."));
270
+ console.log(` ${ok("✓")} Local edits stream in live while ${color.bold("niro watch")} is running.`);
271
+ console.log(color.dim("\n When your branch is merged: `niro discard-temp-project` deletes the temporary project;"));
272
+ console.log(color.dim(" routing falls back to the original project by itself."));
273
273
  }
274
274
 
275
275