@niroai/niro 0.12.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,196 @@ Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.15.0 — 2026-09-21
8
+
9
+ ### Twenty more file types are offered for upload
10
+
11
+ `niro init` and the folder sync decide which files to offer the server from a fixed list of
12
+ extensions (`src/lib/folderSync.js`). That list was a hand-kept copy of the server's own list and
13
+ had fallen behind it in two places:
14
+
15
+ - the ten C++ extensions the parser routes to its C++ grammar besides `.cpp`, `.h` and `.hpp`:
16
+ `.cc`, `.cxx`, `.hxx`, `.hh`, `.h++`, `.c++`, `.tcc`, `.ipp`, `.inl`, `.tpp`. A C++ project with
17
+ `include/widget.hxx` and `detail/vector.tcc` never had those two files in its graph, because the
18
+ CLI never offered them.
19
+ - ten more the parser has always understood but the CLI never offered: `.kts`, `.csx`, `.rake`,
20
+ `.gemspec`, `.phtml`, `.php3`, `.php4`, `.php5`, `.pyi`, `.pyw`.
21
+
22
+ A test now reads the server's Java list and fails if the two drift apart again
23
+ (`test/sourceExtensionDrift.test.js`); the server carries the mirror-image test.
24
+
25
+ The server's own filter remains the final say, so an older CLI simply offers fewer files and a
26
+ newer CLI against an older server has its extra offers declined. Either order of upgrade is safe;
27
+ the new files reach the graph once both sides are current (ai-orchestrator `cadb5360` or later).
28
+
29
+ Note: 0.14.0 was cut in this repository but never published to npm, so upgrading from 0.13.0 brings
30
+ the 0.14.0 change below as well.
31
+
32
+ ### A file whose name contains `+` no longer stops a folder syncing
33
+
34
+ After `niro init`, the server replies with the patterns saying which paths belong to the
35
+ project, and the watch daemon checks every path against them: on each file event, on each
36
+ delta and backstop pass, and once more before it asks the server to delete anything.
37
+
38
+ For a file at the top level of a repository, the pattern the server sends is the filename
39
+ itself. The daemon turned patterns into regular expressions but escaped only the dot, so a
40
+ name carrying a character that means something in a regular expression was read as an
41
+ instruction rather than as a name. With `main.c++` at a repository root, `main.c++` became
42
+ `/^main\.c++$/`, which is not a valid expression. Every sync pass for that folder then failed.
43
+ The daemon stayed up and printed nothing, so the folder simply went quiet.
44
+
45
+ The `.c++` and `.h++` file types added in this same release are what would have exposed it. The
46
+ same flaw had a quieter form that needed no C++ at all: a root-level file called `app (copy).js`
47
+ built a valid expression with a capture group, so the file stopped matching its own pattern and
48
+ was never synced again. A directory named `libstdc++` hit the throwing form, because saying yes
49
+ to "New directory detected. Add to sync?" stores the directory's own name as a pattern.
50
+
51
+ The matcher now escapes every character that has a special meaning, leaving only `*` and `**`
52
+ as wildcards, so a pattern can no longer fail to compile. Behaviour for patterns that already
53
+ worked is unchanged: old and new were compared over 10,701 path-and-pattern pairs taken from
54
+ three real repositories and agreed on every one.
55
+
56
+ ## 0.14.0 — 2026-09-19
57
+
58
+ ### `niro init` on a folder of repos now indexes each repo, not the folder
59
+
60
+ If you run `niro init` in a folder that is not itself a git repository but contains repositories —
61
+ an `apps/` folder, a `~/projects/github/<org>/` folder, anything that groups checkouts — each
62
+ repository is now onboarded in its own right, with its own git URL and its own branch, into one
63
+ project.
64
+
65
+ Before this, the folder itself was registered as a single repo. A folder has no git remote, so it
66
+ got a synthetic `local://<folder-name>` URL and no branch at all, and the monorepo splitter then
67
+ tried to recover the structure by reading folder layout. On one customer's tree that produced 92
68
+ internal repos from 14 real ones: 92 of 92 carried `local://apps`, none had a branch, 58 of them
69
+ were vendored copies and reference checkouts of other people's projects, and 45% of the indexing
70
+ spend went on code the customer had never written. Because no repo had a URL of its own, MCP could
71
+ not match a repo from inside its own folder, and kept offering to create a temporary project in
72
+ code that was already indexed.
73
+
74
+ The repositories underneath were the boundaries the customer had already declared. Reading them is
75
+ better than re-deriving them.
76
+
77
+ What you will see:
78
+
79
+ ```
80
+ apps is not a git repository, but contains 14 that are.
81
+ Niro will index each repo separately, with its own git URL and branch.
82
+
83
+ + voibe-backend
84
+ + voibe_app
85
+ - legal-docs — not a git repository, NOT indexed
86
+ ```
87
+
88
+ Folders holding code but no repository are reported rather than skipped in silence: from that point
89
+ on, editing a file in one of them does nothing, and you should know which ones they are. Run
90
+ `niro init` inside one to add it.
91
+
92
+ The scan stops at the first repository it finds down any path, so a repository vendored inside
93
+ another — a submodule, a `third_party/` checkout — is indexed as part of its parent rather than as a
94
+ service of its own.
95
+
96
+ Unchanged: a folder that IS a git repository still indexes whole, submodules and all. A folder with
97
+ no git anywhere still uploads as one repo, as before.
98
+
99
+ ### A folder whose repositories sit deeper than one level is no longer refused
100
+
101
+ `niro init` requires a build file at the folder or just below it, which is right for a folder of
102
+ documents and wrong for `~/projects/github/<org>/<repo>`, where the repositories are three levels
103
+ down and every folder above them is empty. That was refused with "No project found" and a pointer to
104
+ `--force`, and `--force` would then upload the entire tree as one repo. A folder holding
105
+ repositories is now exempt from that rule, because it has already declared its projects.
106
+
107
+ The refusal message for a folder that genuinely holds nothing indexable now says what Niro looks
108
+ for, and suggests running from the folder where the code actually is.
109
+
110
+ ### Fixed
111
+
112
+ - The background watcher was being installed once per repository when onboarding several at once,
113
+ asking for your password each time. It is one process; it is now set up once per run.
114
+
115
+
116
+ ## 0.13.0 — 2026-09-08
117
+
118
+ ### Upgrading
119
+
120
+ You do not have `niro upgrade` yet, so this one takes two commands:
121
+
122
+ ```bash
123
+ npm install -g @niroai/niro@latest
124
+ niro upgrade --finish
125
+ ```
126
+
127
+ The first gets the package. The second is the half npm cannot do: it re-runs the installer for
128
+ every assistant you have configured and restarts the background watcher. Then restart your
129
+ assistant. Skipping the second command leaves you on this version with the previous version's
130
+ hooks, skills and instruction files, which is the exact problem this release exists to fix.
131
+
132
+ From here on it is one command, `niro upgrade`, which does all of it.
133
+
134
+ ### `niro upgrade` gets the new version AND the things a new version cannot bring with it
135
+
136
+ Upgrading was `npm install -g @niroai/niro@latest`, and every release note in this file said so and
137
+ nothing else. That command is not enough, and has never been enough. The MCP entry is fine: it runs
138
+ plain `niro` off your `PATH` with no version in it, so the new server is picked up for free. But the
139
+ hooks, the skills and the CLAUDE.md/AGENTS.md/rules text are written into your home folder as fixed
140
+ text at install time, not read from the package as it runs. npm cannot touch them. Nothing told you
141
+ that, and nothing re-ran the installer, so a machine that installed once in June was still running
142
+ June's instructions in September while reporting the current version. The background watcher had the
143
+ same problem from the other end: it is a long-running process holding the code it loaded at start,
144
+ so replacing files on disk left it on the old version until something restarted it.
145
+
146
+ `niro upgrade` does all three. It replaces the package, re-runs the installer for every assistant it
147
+ can prove is configured, and restarts the watcher if it is running.
148
+
149
+ It runs as two processes, and the reason is worth writing down. Every command in this CLI is loaded
150
+ lazily, inside its own action handler, and `npm install -g` replaces the folder those files are
151
+ loaded from. One process that ran npm and then carried on would be running the modules it loaded
152
+ before the swap alongside modules loaded after it. So phase one runs npm, executes the NEW binary
153
+ with `--finish`, and stops, requiring nothing in between; phase two is a fresh process reading the
154
+ new tree. `niro start` has always dodged the same trap the same way, by only installing globally
155
+ when it is running from the npx cache and then handing over.
156
+
157
+ Which assistants to refresh is worked out by looking, because nothing on disk records what you
158
+ chose. All 14 clients are covered, because the installer leaves a file this repo owns in every case:
159
+ eight write a JSON config whose path `setup.js` builds, and the other six also get a marker appended
160
+ to a markdown file written here and stripped by `teardown.js` (`~/.claude/CLAUDE.md`,
161
+ `~/.gemini/GEMINI.md`, `~/.copilot/copilot-instructions.md`, `~/.factory/AGENTS.md`), plus codex's
162
+ `[mcp_servers.niro]` table and goose's `extensions:` entry. The marker is appended only after the
163
+ registration command succeeded, so its presence means the client really was configured. Detection is
164
+ read-only, and it is deliberately strict: a client whose entry is `enabled: false` or
165
+ `disabled: true` is one you switched off, so it is left alone rather than switched back on; a
166
+ marker has to be on a line of its own, because a sentence mentioning it is not an install; and every
167
+ path has to be absolute.
168
+
169
+ `niro upgrade --finish` on its own skips npm and only refreshes the assistant files and the watcher,
170
+ which is what you want if you already ran `npm install -g` by hand.
171
+
172
+ Without a usable home folder it checks nothing and says so. Every path would otherwise resolve
173
+ against the current folder, which is how the reverted serve-time self-heal came to write into a
174
+ project's own `.claude/settings.json` (see the 2026-07-30 revert). "Usable" means absolute, and it
175
+ also means rejecting a `HOME` set to the empty string even when `USERPROFILE` would fill in, because
176
+ four of the client path builders read `HOME` directly and would otherwise hand back a relative path.
177
+ This command never writes a settings file itself: refreshing hooks means spawning the existing
178
+ installer, once per client.
179
+
180
+ It refuses to upgrade a copy you are not running. The first real run of this command was from a
181
+ checkout linked with `npm link`, and `npm install -g` deleted that link and put the published
182
+ package in its place. So before npm is allowed to touch anything, `niro upgrade` looks at which
183
+ copy is running: the global package folder being a symlink means `npm link`, and it stops and says
184
+ so; a checkout or an npx copy that is not the global install stops too. `--force` overrides either.
185
+
186
+ It also asks the version it just installed whether it has an `upgrade` command before handing over
187
+ to it, because the newest published version can predate the one you are running. Without that
188
+ check the hand-over fails with a bare `error: unknown command 'upgrade'` and the advice printed is
189
+ to re-run a command that does not exist. Now it prints the two commands that do work.
190
+
191
+ Failures are visible. Each one has its own exit code (npm failed, no binary after npm, hand-over
192
+ failed, refresh incomplete), an interrupted npm is reported as interrupted rather than as "nothing
193
+ changed", a hand-over that cannot start prints why, and the hand-over carries a marker so a version
194
+ that does not understand `--finish` cannot loop back into running npm again.
195
+
196
+
7
197
  ## 0.12.0 — 2026-09-06
8
198
 
9
199
  ### Before an edit to indexed source, Claude is asked to map the method and to verify those flows afterwards
package/README.md CHANGED
@@ -71,6 +71,12 @@ Rebuild the index by hand and watch it finish. Like `niro status`, it picks the
71
71
  branch you are on, so on a branch with a temporary project it rebuilds that one. Name a project to
72
72
  rebuild a different one.
73
73
 
74
+ ```bash
75
+ niro upgrade
76
+ ```
77
+ Get the latest version, and bring your hooks, skills and watcher with it. See
78
+ [Upgrading](#upgrading).
79
+
74
80
  ```bash
75
81
  niro doctor
76
82
  ```
@@ -254,6 +260,58 @@ Watch this folder in the foreground instead, for one session.
254
260
 
255
261
  ---
256
262
 
263
+ ## Upgrading
264
+
265
+ **Check which you are on first:**
266
+
267
+ ```bash
268
+ niro --version
269
+ ```
270
+
271
+ **On 0.13.0 or newer**, one command:
272
+
273
+ ```bash
274
+ niro upgrade
275
+ ```
276
+
277
+ Replaces the package with the latest published version, then puts back the two things replacing the
278
+ package cannot reach: it re-runs the installer for every assistant you have configured, and
279
+ restarts the background watcher. Restart your assistant afterwards.
280
+
281
+ **On anything older than 0.13.0**, you do not have `niro upgrade` yet, so it is two commands, once:
282
+
283
+ ```bash
284
+ npm install -g @niroai/niro@latest
285
+ niro upgrade --finish
286
+ ```
287
+
288
+ The first gets the package. The second does the half npm cannot: your hooks, skills and the
289
+ watcher. After that, `niro upgrade` on its own is enough. Do not stop after the first command:
290
+ that leaves you on the new version running the old version's hooks, skills and instruction files,
291
+ which is the problem `niro upgrade` exists to solve.
292
+
293
+ **Why it is more than an npm install.** The hooks, skills and instruction files are written into
294
+ your home folder as fixed text when you install, not read out of the package as it runs, so a newer
295
+ package does not change them. The watcher is a long-running process still holding the code it
296
+ started with. The MCP config is the one part that needs nothing done to it: it runs plain `niro`
297
+ from your `PATH` with no version pinned in it, so the new server is picked up on its own.
298
+
299
+ **Restart your assistant last.** Skills and hooks are read when it starts, so nothing above reaches
300
+ a session that is already open.
301
+
302
+ Fully by hand, if you would rather:
303
+
304
+ ```bash
305
+ npm install -g @niroai/niro@latest
306
+ niro mcp install # once per assistant you use
307
+ niro service install # only if you run the watcher
308
+ ```
309
+
310
+ `niro start` upgrades nothing. On a machine that has connected an assistant once it skips the
311
+ connect step, so the old hooks and skills stay where they are.
312
+
313
+ ---
314
+
257
315
  ## Reference
258
316
 
259
317
  Add `--help` to any command for its flags. Add `--json` to `whoami`, `info`, `status`,
@@ -302,6 +360,10 @@ does nothing until you do.
302
360
 
303
361
  **Auto-sync is not running.** `npm install -g pm2`, then `niro service install`.
304
362
 
363
+ **A command or feature in the changelog is missing.** You are on an older version. Check with
364
+ `niro --version`, then see [Upgrading](#upgrading): before 0.13.0 it takes two commands, after it
365
+ one. Plain `npm install -g` on its own always leaves your hooks and skills on the old version.
366
+
305
367
  **A folder was refused.** Niro will not index your home folder, or a folder with no build file
306
368
  in it. Point it at the repo you meant.
307
369
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.12.0",
4
- "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
3
+ "version": "0.15.0",
4
+ "description": "Niro \u2014 one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
7
7
  "mcp",
@@ -41,7 +41,7 @@ const color = require("../lib/color");
41
41
  const watchDaemon = require("../lib/watchDaemon");
42
42
  const { login } = require("./login");
43
43
  const { watchStatus } = require("./build");
44
- const { currentIndent } = require("../lib/nestedOutput");
44
+ const { currentIndent, withIndent } = require("../lib/nestedOutput");
45
45
 
46
46
  /** Depth as spaces, for the \r-written lines that bypass withIndent's console wrappers. */
47
47
  function nestPad() {
@@ -99,9 +99,24 @@ async function init(pathArg, opts = {}) {
99
99
  // repo for must not start failing its re-run just because it has no build file.
100
100
  // See src/lib/onboardTarget.js.
101
101
  const knownEntry = watchState.get(targetDir, apiUrlBeforeLogin);
102
+ // A FOLDER THAT HOLDS GIT REPOS ALREADY DECLARES ITS PROJECTS, just not with a build file.
103
+ //
104
+ // The descriptor rule looks for package.json / pom.xml / go.mod in this folder or just below it,
105
+ // and refuses when it finds none. That is right for a folder of holiday photos. It is wrong for
106
+ // ~/projects/github/<org>/<repo>, where the repos are three levels down and the folders above them
107
+ // hold nothing at all: the scan found no descriptor, the user was told "No project found" and
108
+ // pointed at --force, and --force would then upload the whole tree as ONE repo — the exact outcome
109
+ // this release exists to prevent. The comment on CHILD_SCAN_MAX_DEPTH calls that layout the
110
+ // commonest one on disk. Reproduced on a demo tree on 2026-09-19 before this line existed.
111
+ //
112
+ // Cheap because it stops at the first repo it sees, and only runs when the descriptor scan already
113
+ // came back empty. Every other refusal in ensureOnboardable (home directory, filesystem root) still
114
+ // applies: this exempts the descriptor rule alone.
115
+ const holdsGitRepos = () => collectNestedGitRepos(targetDir).repos.length > 0;
102
116
  if (!onboardTarget.ensureOnboardable(targetDir, {
103
117
  ...opts,
104
118
  alreadyRegistered: !!(knownEntry && knownEntry.gitId),
119
+ hasGitReposBeneath: holdsGitRepos,
105
120
  })) {
106
121
  process.exitCode = 1;
107
122
  return;
@@ -244,6 +259,25 @@ async function init(pathArg, opts = {}) {
244
259
  // onboarded, so resolve each immediate git-repo child against the server too — this
245
260
  // catches children indexed via the web UI / as separate repos.
246
261
  const childScan = await scanIndexedChildren(targetDir, opts);
262
+
263
+ // THE FOLDER IS NOT A REPO BUT CONTAINS SOME: onboard each of them, not the folder.
264
+ //
265
+ // `git.inspect` walks UPWARDS, so repoRoot can be an ANCESTOR of targetDir. "This folder is a
266
+ // repo" therefore has to compare paths, not just test for a truthy repoRoot.
267
+ //
268
+ // Why this exists. Onboarding the parent registers ONE repo, and a folder with no git remote gets
269
+ // the synthetic `local://<name>` URL from step 4. Measured on a customer's tree on 2026-09-18:
270
+ // `niro init` in an `apps/` folder holding 14 real repos produced 92 internal repos, every one of
271
+ // them carrying `local://apps` and a null branch — so no repo had its own identity, none could be
272
+ // matched by MCP from its own folder (it kept offering to create a temp project inside indexed
273
+ // code), and nothing could track a per-repo branch. The repos beneath were the customer's own
274
+ // declared boundaries; the split was re-deriving them, badly, from folder structure.
275
+ const targetIsRepoRoot = !!gitInfo.repoRoot
276
+ && path.resolve(gitInfo.repoRoot) === path.resolve(targetDir);
277
+ if (!targetIsRepoRoot && childScan.all.length) {
278
+ return onboardDiscoveredRepos({ targetDir, defaultName, childScan, opts });
279
+ }
280
+
247
281
  if (childScan.indexed.length) {
248
282
  console.error(fail(`\n This folder contains ${childScan.indexed.length} repo(s) already indexed in Niro:`));
249
283
  for (const c of childScan.indexed) {
@@ -642,7 +676,14 @@ async function init(pathArg, opts = {}) {
642
676
  }
643
677
 
644
678
  // --defaults promised "no prompts": never surface the PM2 install offer there.
645
- const daemon = await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
679
+ // INSTALLED ONCE PER RUN, not once per repo. The daemon is a single global PM2 process that reads
680
+ // ~/.niro/projects.json, so a second install is a no-op that still shells out to `pm2 startup`,
681
+ // which asks for sudo. Onboarding a folder of 6 repos asked six times and printed six identical
682
+ // sudo failures (seen on the demo tree, 2026-09-18); the caller that onboards several repos
683
+ // installs it itself, after the last one.
684
+ const daemon = opts.skipDaemonSetup
685
+ ? { ok: true }
686
+ : await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
646
687
 
647
688
  // The closing banner must not read as an all-clear when auto-sync is dead —
648
689
  // customers were only discovering the missing daemon after a reboot.
@@ -694,6 +735,135 @@ const CHILD_SCAN_MAX = 60; // bound the number of nested repos we resolve
694
735
  // single repo and 50 for this monorepo, versus 5 and 13 at depth 2.
695
736
  const CHILD_SCAN_MAX_DEPTH = 4;
696
737
 
738
+ /**
739
+ * Onboard every git repo found beneath a folder that is not itself a repo, into ONE project.
740
+ *
741
+ * <b>Why one project and not one per repo.</b> Cross-service answers — who calls this endpoint, what
742
+ * breaks if I change this, trace this request across services — resolve by unioning the repos of a
743
+ * PROJECT. Fourteen projects would give fourteen islands and lose the thing the customer is paying
744
+ * for. So: one project, N repos, each carrying its own origin URL and its own HEAD branch.
745
+ *
746
+ * <b>Why each repo goes through init() again rather than a bespoke path.</b> Registering a repo is
747
+ * not one call: it dedupes against the same repo in this and other projects, reuses a gitId rather
748
+ * than re-uploading, filters the manifest, pushes include/exclude patterns, scans env vars and writes
749
+ * the per-folder watch entry. `start.js` already re-enters init this way with `nested: true`. A second
750
+ * implementation of that would drift from this one within a release.
751
+ *
752
+ * Folders holding code but no repo are REPORTED, never silently skipped: from this point on, editing
753
+ * a file in one of them does nothing at all, and the user has to have been told once. Silently
754
+ * dropping code is the failure ADR-023 was written against.
755
+ */
756
+ async function onboardDiscoveredRepos({ targetDir, defaultName, childScan, opts },
757
+ runInit = init, readEntry = watchState.peek) {
758
+ const already = childScan.indexed;
759
+ const fresh = childScan.all.filter(
760
+ (r) => !already.some((c) => path.resolve(c.path) === path.resolve(r))
761
+ );
762
+ const codeFoldersWithoutRepo = nonRepoCodeFolders(targetDir, childScan.all);
763
+
764
+ console.log(`\n ${ok(`${path.basename(targetDir)} is not a git repository, but contains ${childScan.all.length} that are.`)}`);
765
+ console.log(` Niro will index each repo separately, with its own git URL and branch.\n`);
766
+ for (const r of fresh) console.log(` ${ok("+")} ${path.relative(targetDir, r) || path.basename(r)}`);
767
+
768
+ // Already indexed elsewhere: SKIPPED, not a reason to refuse the rest.
769
+ //
770
+ // The whole-run refusal further down exists because onboarding the PARENT would upload that child's
771
+ // content a second time. Here the parent is never uploaded, so that reasoning does not apply, and
772
+ // refusing thirteen new repos because the fourteenth is known would be the wrong answer.
773
+ for (const c of already) {
774
+ const where = c.projects.map((x) => `"${x.alias || x.project_id}"`).join(", ");
775
+ console.log(` ${warn("-")} ${path.relative(targetDir, c.path)} — already indexed in ${where}, skipping`);
776
+ }
777
+ for (const f of codeFoldersWithoutRepo) {
778
+ console.log(` ${warn("-")} ${path.relative(targetDir, f)} — not a git repository, NOT indexed`);
779
+ }
780
+ if (childScan.unverified.length) {
781
+ console.log(` ${warn("?")} ${childScan.unverified.length} repo(s) could not be checked against the server; they will be onboarded.`);
782
+ }
783
+ if (childScan.truncated) {
784
+ console.log(color.dim(` (Stopped after the first ${CHILD_SCAN_MAX} repos; deeper ones were not scanned.)`));
785
+ }
786
+
787
+ if (!fresh.length) {
788
+ console.log(`\n ${warn("Nothing new to index.")} Every repo here is already in Niro.`);
789
+ return;
790
+ }
791
+ if (codeFoldersWithoutRepo.length) {
792
+ console.log(color.dim(`\n Folders marked NOT indexed will not sync. Run \`niro init\` inside one to add it.`));
793
+ }
794
+
795
+ if (!opts.defaults && !opts.force && !prompt.isNonInteractive()) {
796
+ if (!(await prompt.confirm(`\n Index these ${fresh.length} repo(s)?`, true))) return;
797
+ }
798
+
799
+ // The FIRST repo creates the project; the rest attach to whatever it created. Hoisting project
800
+ // creation out of init() instead would mean duplicating the new-vs-existing resolution, the name
801
+ // availability check and the alias-to-project_id reassignment that init already does.
802
+ let projectRef = opts.addTo || null;
803
+ let onboarded = 0;
804
+ for (const repoPath of fresh) {
805
+ const name = path.relative(targetDir, repoPath) || path.basename(repoPath);
806
+ console.log(`\n ${ok("▸")} ${name}`);
807
+ const childOpts = {
808
+ ...opts,
809
+ nested: true,
810
+ // One build for the project at the end, not one per repo.
811
+ confirmBuild: "no",
812
+ // ...and one watch-daemon install for the run, done below once every repo is in.
813
+ skipDaemonSetup: true,
814
+ // The project name is the FOLDER's, not the first repo's — `apps` reads better than whichever
815
+ // repo happened to sort first.
816
+ projectName: projectRef ? undefined : (opts.projectName || defaultName),
817
+ addTo: projectRef || undefined,
818
+ };
819
+ try {
820
+ await withIndent(4, () => runInit(repoPath, childOpts));
821
+ } catch (err) {
822
+ console.error(fail(` Could not onboard ${name}: ${err.message}`));
823
+ continue;
824
+ }
825
+ const entry = readEntry(repoPath);
826
+ if (entry && entry.projectId) {
827
+ onboarded += 1;
828
+ if (!projectRef) projectRef = entry.projectId;
829
+ }
830
+ }
831
+
832
+ if (!projectRef || !onboarded) {
833
+ console.error(fail(`\n No repo could be onboarded; nothing was created.`));
834
+ process.exitCode = 1;
835
+ return;
836
+ }
837
+ const daemon = await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
838
+ if (!daemon.ok) {
839
+ console.log(`\n ${warn("auto-sync is OFF")} — these folders will not stay in sync (see above).`);
840
+ }
841
+ console.log(`\n ${ok(`${onboarded} repo(s) indexed into project "${projectRef}".`)}`);
842
+ console.log(` ${color.cyan(`niro build`)} to index them, or ${color.cyan("niro status")} to review first.`);
843
+ }
844
+
845
+ /**
846
+ * Immediate subfolders that hold files but are neither a git repo nor on the way to one.
847
+ *
848
+ * A grouping folder like `services/` that CONTAINS repos is not "skipped code" — its repos are being
849
+ * onboarded. Only a folder with no repo anywhere beneath it is genuinely being left out.
850
+ */
851
+ function nonRepoCodeFolders(targetDir, discoveredRepos) {
852
+ const out = [];
853
+ let entries;
854
+ try { entries = fs.readdirSync(targetDir, { withFileTypes: true }); } catch { return out; }
855
+ for (const e of entries) {
856
+ if (!e.isDirectory() || e.name.startsWith(".") || CHILD_SCAN_SKIP.has(e.name)) continue;
857
+ const dir = path.join(targetDir, e.name);
858
+ const leadsToARepo = discoveredRepos.some((r) => {
859
+ const rel = path.relative(dir, r);
860
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
861
+ });
862
+ if (!leadsToARepo) out.push(dir);
863
+ }
864
+ return out;
865
+ }
866
+
697
867
  /**
698
868
  * Collect directories under `root` that are their OWN git repo (have a `.git` entry),
699
869
  * scanning immediate children AND one level of NON-git grouping dirs (so `services/foo`,
@@ -741,7 +911,9 @@ function collectNestedGitRepos(root) {
741
911
  */
742
912
  async function scanIndexedChildren(targetDir, opts, resolve = resolveContext) {
743
913
  const { repos: gitChildren, truncated } = collectNestedGitRepos(targetDir);
744
- if (!gitChildren.length) return { indexed: [], unverified: [], truncated };
914
+ // `all` is every git repo found beneath targetDir, indexed or not. The refusal below only ever
915
+ // needed the indexed ones; onboarding them (onboardDiscoveredRepos) needs the whole list.
916
+ if (!gitChildren.length) return { indexed: [], unverified: [], truncated, all: [] };
745
917
 
746
918
  const indexed = [];
747
919
  const unverified = [];
@@ -767,7 +939,7 @@ async function scanIndexedChildren(targetDir, opts, resolve = resolveContext) {
767
939
  else unverified.push(r);
768
940
  }
769
941
  }
770
- return { indexed, unverified, truncated };
942
+ return { indexed, unverified, truncated, all: gitChildren };
771
943
  }
772
944
 
773
945
  /**
@@ -1226,4 +1398,6 @@ function logicalCwd() {
1226
1398
  return process.cwd();
1227
1399
  }
1228
1400
 
1229
- module.exports = { init, parseOnOff, parseYesNo, parseQuietPeriod, promptEnvVars, promptActivePropertyFiles, scanIndexedChildren };
1401
+ module.exports = {
1402
+ onboardDiscoveredRepos,
1403
+ nonRepoCodeFolders, init, parseOnOff, parseYesNo, parseQuietPeriod, promptEnvVars, promptActivePropertyFiles, scanIndexedChildren };
@@ -2,19 +2,28 @@
2
2
  * `niro mcp install` / `niro mcp uninstall` / `niro mcp serve` — drive the in-package MCP server.
3
3
  *
4
4
  * install(): spawn the bundled editor-config wizard as a child so it can prompt.
5
+ * Takes optional wizard arguments; see runWizard.
5
6
  * uninstall(): spawn the teardown wizard — the exact inverse of install.
6
7
  * serve(): run the in-package stdio bridge directly in this process so the
7
8
  * editor's MCP stdio talks straight to it.
8
9
  */
9
10
  const path = require("path");
10
- const { spawn } = require("child_process");
11
+ const childProcess = require("child_process");
11
12
 
12
- /** Spawn a bundled wizard (setup.js / teardown.js) as a child so it can prompt on the TTY. */
13
- function runWizard(scriptName, label) {
13
+ /**
14
+ * Spawn a bundled wizard (setup.js / teardown.js) as a child so it can prompt on the TTY.
15
+ *
16
+ * `args` overrides what is forwarded to the wizard. Callers that are themselves a `niro mcp ...`
17
+ * command leave it out and the user's own flags after `niro mcp install` are passed through.
18
+ * `niro upgrade` passes its own `--client <id>`, because the flags on its command line
19
+ * (`--finish`, `--from`) are not the wizard's.
20
+ */
21
+ function runWizard(scriptName, label, args) {
22
+ const wizardArgs = Array.isArray(args) ? args : process.argv.slice(4);
14
23
  return new Promise((resolve, reject) => {
15
- const child = spawn(
24
+ const child = childProcess.spawn(
16
25
  process.execPath,
17
- [path.join(__dirname, "..", "mcp", scriptName), ...process.argv.slice(4)],
26
+ [path.join(__dirname, "..", "mcp", scriptName), ...wizardArgs],
18
27
  { stdio: "inherit" }
19
28
  );
20
29
  child.on("error", reject);
@@ -25,8 +34,8 @@ function runWizard(scriptName, label) {
25
34
  });
26
35
  }
27
36
 
28
- async function install() {
29
- return runWizard("setup.js", "setup");
37
+ async function install(args) {
38
+ return runWizard("setup.js", "setup", args);
30
39
  }
31
40
 
32
41
  async function uninstall() {
@@ -560,6 +560,7 @@ module.exports = {
560
560
  printNextSteps,
561
561
  BOOK_A_CALL,
562
562
  runningFromNpxCache,
563
+ globalNiroPath,
563
564
  isOlder,
564
565
  describeProblem,
565
566
  expandHome,