@niroai/niro 0.12.0 → 0.13.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,87 @@ Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.13.0 — 2026-09-08
8
+
9
+ ### Upgrading
10
+
11
+ You do not have `niro upgrade` yet, so this one takes two commands:
12
+
13
+ ```bash
14
+ npm install -g @niroai/niro@latest
15
+ niro upgrade --finish
16
+ ```
17
+
18
+ The first gets the package. The second is the half npm cannot do: it re-runs the installer for
19
+ every assistant you have configured and restarts the background watcher. Then restart your
20
+ assistant. Skipping the second command leaves you on this version with the previous version's
21
+ hooks, skills and instruction files, which is the exact problem this release exists to fix.
22
+
23
+ From here on it is one command, `niro upgrade`, which does all of it.
24
+
25
+ ### `niro upgrade` gets the new version AND the things a new version cannot bring with it
26
+
27
+ Upgrading was `npm install -g @niroai/niro@latest`, and every release note in this file said so and
28
+ nothing else. That command is not enough, and has never been enough. The MCP entry is fine: it runs
29
+ plain `niro` off your `PATH` with no version in it, so the new server is picked up for free. But the
30
+ hooks, the skills and the CLAUDE.md/AGENTS.md/rules text are written into your home folder as fixed
31
+ text at install time, not read from the package as it runs. npm cannot touch them. Nothing told you
32
+ that, and nothing re-ran the installer, so a machine that installed once in June was still running
33
+ June's instructions in September while reporting the current version. The background watcher had the
34
+ same problem from the other end: it is a long-running process holding the code it loaded at start,
35
+ so replacing files on disk left it on the old version until something restarted it.
36
+
37
+ `niro upgrade` does all three. It replaces the package, re-runs the installer for every assistant it
38
+ can prove is configured, and restarts the watcher if it is running.
39
+
40
+ It runs as two processes, and the reason is worth writing down. Every command in this CLI is loaded
41
+ lazily, inside its own action handler, and `npm install -g` replaces the folder those files are
42
+ loaded from. One process that ran npm and then carried on would be running the modules it loaded
43
+ before the swap alongside modules loaded after it. So phase one runs npm, executes the NEW binary
44
+ with `--finish`, and stops, requiring nothing in between; phase two is a fresh process reading the
45
+ new tree. `niro start` has always dodged the same trap the same way, by only installing globally
46
+ when it is running from the npx cache and then handing over.
47
+
48
+ Which assistants to refresh is worked out by looking, because nothing on disk records what you
49
+ chose. All 14 clients are covered, because the installer leaves a file this repo owns in every case:
50
+ eight write a JSON config whose path `setup.js` builds, and the other six also get a marker appended
51
+ to a markdown file written here and stripped by `teardown.js` (`~/.claude/CLAUDE.md`,
52
+ `~/.gemini/GEMINI.md`, `~/.copilot/copilot-instructions.md`, `~/.factory/AGENTS.md`), plus codex's
53
+ `[mcp_servers.niro]` table and goose's `extensions:` entry. The marker is appended only after the
54
+ registration command succeeded, so its presence means the client really was configured. Detection is
55
+ read-only, and it is deliberately strict: a client whose entry is `enabled: false` or
56
+ `disabled: true` is one you switched off, so it is left alone rather than switched back on; a
57
+ marker has to be on a line of its own, because a sentence mentioning it is not an install; and every
58
+ path has to be absolute.
59
+
60
+ `niro upgrade --finish` on its own skips npm and only refreshes the assistant files and the watcher,
61
+ which is what you want if you already ran `npm install -g` by hand.
62
+
63
+ Without a usable home folder it checks nothing and says so. Every path would otherwise resolve
64
+ against the current folder, which is how the reverted serve-time self-heal came to write into a
65
+ project's own `.claude/settings.json` (see the 2026-07-30 revert). "Usable" means absolute, and it
66
+ also means rejecting a `HOME` set to the empty string even when `USERPROFILE` would fill in, because
67
+ four of the client path builders read `HOME` directly and would otherwise hand back a relative path.
68
+ This command never writes a settings file itself: refreshing hooks means spawning the existing
69
+ installer, once per client.
70
+
71
+ It refuses to upgrade a copy you are not running. The first real run of this command was from a
72
+ checkout linked with `npm link`, and `npm install -g` deleted that link and put the published
73
+ package in its place. So before npm is allowed to touch anything, `niro upgrade` looks at which
74
+ copy is running: the global package folder being a symlink means `npm link`, and it stops and says
75
+ so; a checkout or an npx copy that is not the global install stops too. `--force` overrides either.
76
+
77
+ It also asks the version it just installed whether it has an `upgrade` command before handing over
78
+ to it, because the newest published version can predate the one you are running. Without that
79
+ check the hand-over fails with a bare `error: unknown command 'upgrade'` and the advice printed is
80
+ to re-run a command that does not exist. Now it prints the two commands that do work.
81
+
82
+ Failures are visible. Each one has its own exit code (npm failed, no binary after npm, hand-over
83
+ failed, refresh incomplete), an interrupted npm is reported as interrupted rather than as "nothing
84
+ changed", a hand-over that cannot start prints why, and the hand-over carries a marker so a version
85
+ that does not understand `--finish` cannot loop back into running npm again.
86
+
87
+
7
88
  ## 0.12.0 — 2026-09-06
8
89
 
9
90
  ### 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
@@ -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,
@@ -0,0 +1,400 @@
1
+ /**
2
+ * `niro upgrade` — replace this install with the latest published package, then put back the
3
+ * things replacing the package cannot put back for itself.
4
+ *
5
+ * Why this runs as two processes.
6
+ *
7
+ * Every command in this CLI is loaded lazily, inside its own action handler (src/index.js), and
8
+ * `npm install -g` replaces the folder those files are loaded from. A single process that ran npm
9
+ * and then carried on would be running the modules it loaded before the swap alongside modules
10
+ * loaded after it. Nothing tests that mixture. start.js has always avoided the same trap the same
11
+ * way: bootstrapIfNpx installs globally only when it is running from the npx cache, so it never
12
+ * overwrites the tree it is executing, and then it hands over.
13
+ *
14
+ * phase one, this copy run npm, then exec the NEW global binary with --finish and stop.
15
+ * phase two, the new copy a fresh process reading the new tree: restart the watcher, refresh
16
+ * each configured client's hooks and skills, ask for an assistant
17
+ * restart.
18
+ *
19
+ * Phase one requires nothing between the npm call and the hand-over, and it also catches its own
20
+ * errors rather than letting them reach src/index.js `run()`, whose catch does
21
+ * `require("./lib/printError")` — a lazy load out of the folder npm has just replaced. That is the
22
+ * one path where the swap could still bite, so it is closed here rather than described.
23
+ *
24
+ * What it deliberately does not do is write a settings file itself. Refreshing hooks means
25
+ * spawning the existing installer, once per client. A second write path into
26
+ * ~/.claude/settings.json was built once, reviewed three times and reverted on 2026-07-30
27
+ * (d3ff3d8) after it was found deleting users' own hooks, changing that file from mode 0600 to
28
+ * 0644 while it can hold API keys, and, with HOME unset, writing into a project's git-tracked
29
+ * .claude/settings.json. The installer already gets those cases right, and here it runs because
30
+ * the user asked for it.
31
+ */
32
+ const childProcess = require("child_process");
33
+ const fs = require("fs");
34
+ const path = require("path");
35
+
36
+ const color = require("../lib/color");
37
+ const service = require("../lib/service");
38
+ const mcpClients = require("../lib/mcpClients");
39
+ const mcpCmd = require("./mcp");
40
+ const startCmd = require("./start");
41
+
42
+ const PACKAGE_NAME = "@niroai/niro";
43
+ const VERSION = require("../../package.json").version;
44
+
45
+ /**
46
+ * Set on the process phase one hands over to. If phase one ever sees it already set, it is itself
47
+ * a hand-over child that failed to reach phase two, which means the newly installed version does
48
+ * not understand `--finish`. Without this, that version would run npm and hand over again, without
49
+ * bound. start.js guards its own hand-over with BOOTSTRAP_ENV for the same reason.
50
+ */
51
+ const HANDOVER_ENV = "NIRO_UPGRADE_HANDOVER";
52
+
53
+ /** Exit codes, so a script can tell the failures apart. */
54
+ const EXIT_NPM_FAILED = 1;
55
+ const EXIT_NO_BINARY = 2;
56
+ const EXIT_HANDOVER_FAILED = 3;
57
+ const EXIT_REFRESH_INCOMPLETE = 4;
58
+ const EXIT_WRONG_COPY = 5;
59
+
60
+ /** `niro upgrade` (phase one) and `niro upgrade --finish` (phase two). */
61
+ async function upgrade(opts = {}) {
62
+ if (opts.finish) return finish(opts.from);
63
+ return replaceAndHandOver(Boolean(opts.force));
64
+ }
65
+
66
+ /**
67
+ * Phase one. Replace the package, then hand over to the copy that replaced it.
68
+ *
69
+ * Everything this function touches is already loaded by the requires at the top of this file, and
70
+ * every failure is reported here rather than thrown, because a throw would reach a catch that
71
+ * lazily loads a module out of the folder npm has just swapped.
72
+ */
73
+ function replaceAndHandOver(force) {
74
+ if (process.env[HANDOVER_ENV]) {
75
+ console.error(color.red("\n The version just installed does not support `niro upgrade --finish`."));
76
+ console.error(" The package itself is upgraded. Your hooks, skills and watcher are not.");
77
+ console.error(` Finish by hand: ${color.cyan("niro mcp install")} for each assistant you use,`);
78
+ console.error(` then ${color.cyan("niro service install")} if you run the background watcher.\n`);
79
+ process.exitCode = EXIT_HANDOVER_FAILED;
80
+ return;
81
+ }
82
+
83
+ const running = describeRunningCopy();
84
+ if (running.kind === "linked" && !force) {
85
+ console.error(color.yellow("\n Not upgrading: niro is npm-linked to a working copy."));
86
+ console.error(` ${color.cyan("npm prefix -g")} says the global package folder is a link:`);
87
+ console.error(` ${running.pkgDir}`);
88
+ console.error(` -> ${running.linkTarget}`);
89
+ console.error(" `npm install -g` would replace that link with the published package, and");
90
+ console.error(" `niro` would stop running your checkout. Your files would be untouched, but");
91
+ console.error(` you would have to run ${color.cyan("npm link")} in the checkout to get it back.`);
92
+ console.error(` Upgrade anyway with ${color.cyan("niro upgrade --force")}.\n`);
93
+ process.exitCode = EXIT_WRONG_COPY;
94
+ return;
95
+ }
96
+ if (running.kind === "elsewhere" && !force) {
97
+ console.error(color.yellow("\n Not upgrading: you are not running the globally installed niro."));
98
+ console.error(` running: ${running.self || "unknown"}`);
99
+ console.error(` would upgrade: ${running.pkgDir || "the npm global folder"}`);
100
+ console.error(" Those are different copies, so this would replace one you are not using and");
101
+ console.error(" leave the one you are.");
102
+ console.error(` Upgrade the global copy anyway with ${color.cyan("niro upgrade --force")}.\n`);
103
+ process.exitCode = EXIT_WRONG_COPY;
104
+ return;
105
+ }
106
+
107
+ console.log(`\n Upgrading niro. Installed now: ${VERSION}\n`);
108
+
109
+ const installed = childProcess.spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
110
+ shell: true,
111
+ stdio: "inherit",
112
+ });
113
+
114
+ if (installed.signal) {
115
+ // Ctrl+C reaches the whole process group, so npm dies with this process. Whether it left the
116
+ // global folder half-written is npm's business and not something this can know, so it is not
117
+ // claimed either way.
118
+ console.error(color.yellow(`\n Interrupted (${installed.signal}) while npm was running.`));
119
+ console.error(" Whether the package was replaced is unknown. Check with `niro --version`,");
120
+ console.error(` then run ${color.cyan("niro upgrade")} again.\n`);
121
+ process.exitCode = EXIT_NPM_FAILED;
122
+ return;
123
+ }
124
+
125
+ if (installed.status !== 0) {
126
+ console.error(color.red("\n npm could not replace the package."));
127
+ console.error(" The usual cause is that your npm global folder needs root. Find it with");
128
+ console.error(` ${color.cyan("npm prefix -g")}, then either fix its ownership or re-run the install`);
129
+ console.error(" with the privileges it needs:");
130
+ console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}@latest`)}`);
131
+ console.error(` Then run ${color.cyan("niro upgrade --finish")} to refresh your assistant files.\n`);
132
+ process.exitCode = EXIT_NPM_FAILED;
133
+ return;
134
+ }
135
+
136
+ const bin = startCmd.globalNiroPath();
137
+ if (!bin) {
138
+ console.error(color.red("\n npm reported success, but there is no niro in your npm global folder."));
139
+ console.error(` Check ${color.cyan("npm prefix -g")} and that its bin folder is on your PATH.`);
140
+ console.error(" The package is upgraded; only the refresh step was skipped. Run it with");
141
+ console.error(` ${color.cyan("niro upgrade --finish")} once niro is back on PATH.\n`);
142
+ process.exitCode = EXIT_NO_BINARY;
143
+ return;
144
+ }
145
+
146
+ if (!supportsFinish(bin)) {
147
+ // The version just installed has no `upgrade` command, which is what happens when the newest
148
+ // published version predates this one. Saying "re-run niro upgrade --finish" here would send
149
+ // the user to a command that cannot exist.
150
+ console.error(color.yellow("\n The package is upgraded, but the version now installed has no"));
151
+ console.error(" `niro upgrade` command, so it cannot refresh your assistant files itself.");
152
+ console.error(" Finish by hand:");
153
+ console.error(` ${color.cyan("niro mcp install")} once per assistant you use`);
154
+ console.error(` ${color.cyan("niro service install")} if you run the background watcher`);
155
+ console.error(" Then restart your assistant.\n");
156
+ process.exitCode = EXIT_HANDOVER_FAILED;
157
+ return;
158
+ }
159
+
160
+ // The hand-over. A fresh process reads the new tree cleanly; this one cannot. VERSION comes from
161
+ // package.json, so it is a semver string and safe on a Windows command line. The Windows branch
162
+ // is untested: spawning a .cmd shim needs a shell on current Node, and a global prefix
163
+ // containing spaces is why the path is quoted.
164
+ const env = { ...process.env, [HANDOVER_ENV]: "1" };
165
+ const handover = process.platform === "win32"
166
+ ? childProcess.spawnSync(`"${bin}" upgrade --finish --from ${VERSION}`, { stdio: "inherit", shell: true, env })
167
+ : childProcess.spawnSync(bin, ["upgrade", "--finish", "--from", VERSION], { stdio: "inherit", env });
168
+
169
+ if (handover.error) {
170
+ // spawnSync could not start the child at all: a non-executable bin link, a directory where the
171
+ // binary should be, a broken shim. Without this the command exits silently.
172
+ console.error(color.red(`\n Could not run the upgraded niro at ${bin}:`));
173
+ console.error(` ${handover.error.message}`);
174
+ console.error(" The package is upgraded; your hooks, skills and watcher are not.");
175
+ console.error(` Try ${color.cyan("niro upgrade --finish")}, or reinstall with`);
176
+ console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}@latest`)}.\n`);
177
+ process.exitCode = EXIT_HANDOVER_FAILED;
178
+ return;
179
+ }
180
+
181
+ if (handover.signal) {
182
+ console.error(color.yellow(`\n Interrupted (${handover.signal}) while refreshing.`));
183
+ console.error(` The package is upgraded. Finish with ${color.cyan("niro upgrade --finish")}.\n`);
184
+ process.exitCode = EXIT_HANDOVER_FAILED;
185
+ return;
186
+ }
187
+
188
+ if (handover.status !== 0) {
189
+ console.error(`\n The upgraded niro exited with ${handover.status} while refreshing.`);
190
+ console.error(` The package is upgraded. Re-run ${color.cyan("niro upgrade --finish")} to try again.\n`);
191
+ }
192
+ process.exitCode = handover.status === null ? EXIT_HANDOVER_FAILED : handover.status;
193
+ }
194
+
195
+ /** Where npm would install this package globally. */
196
+ function globalPackageDir() {
197
+ const r = childProcess.spawnSync("npm", ["prefix", "-g"], { shell: true, encoding: "utf8" });
198
+ if (r.status !== 0) return null;
199
+ const prefix = String(r.stdout || "").trim();
200
+ if (!prefix) return null;
201
+ return process.platform === "win32"
202
+ ? path.join(prefix, "node_modules", PACKAGE_NAME.replace("/", path.sep))
203
+ : path.join(prefix, "lib", "node_modules", PACKAGE_NAME.replace("/", path.sep));
204
+ }
205
+
206
+ /**
207
+ * Which copy of niro is running this, checked BEFORE npm is allowed to touch anything.
208
+ *
209
+ * "global" this process is the globally installed package, so replacing it is the job
210
+ * "linked" the global package folder is a symlink, which is what `npm link` leaves behind.
211
+ * `npm install -g` deletes that link and puts the published package in its place,
212
+ * so a developer working on niro loses the wiring between `niro` and their checkout.
213
+ * "elsewhere" running from somewhere else entirely: a checkout invoked directly, or the npx
214
+ * cache. Upgrading then replaces a copy the user is not running.
215
+ *
216
+ * This exists because the first real run of `niro upgrade` was from a linked checkout and silently
217
+ * replaced it with the published package.
218
+ */
219
+ function describeRunningCopy(pkgDirOverride) {
220
+ // The directory is a parameter so a unit test can exercise every branch without shelling out to
221
+ // npm at all. An end-to-end rehearsal takes the other route and sets npm_config_prefix, which
222
+ // npm does honour (scripts/rehearse-upgrade.sh).
223
+ const pkgDir = pkgDirOverride === undefined ? globalPackageDir() : pkgDirOverride;
224
+
225
+ let self = null;
226
+ try {
227
+ self = fs.realpathSync(process.argv[1]);
228
+ } catch (err) {
229
+ self = null;
230
+ }
231
+
232
+ if (pkgDir) {
233
+ try {
234
+ if (fs.lstatSync(pkgDir).isSymbolicLink()) {
235
+ return { kind: "linked", pkgDir, linkTarget: fs.realpathSync(pkgDir), self };
236
+ }
237
+ } catch (err) {
238
+ // Not installed globally at all. Falls through to "elsewhere".
239
+ }
240
+ }
241
+
242
+ let pkgReal = null;
243
+ try {
244
+ pkgReal = pkgDir ? fs.realpathSync(pkgDir) : null;
245
+ } catch (err) {
246
+ pkgReal = null;
247
+ }
248
+ if (pkgReal && self && (self === pkgReal || self.startsWith(pkgReal + path.sep))) {
249
+ return { kind: "global", pkgDir, linkTarget: null, self };
250
+ }
251
+ return { kind: "elsewhere", pkgDir, linkTarget: null, self };
252
+ }
253
+
254
+ /**
255
+ * Does the version now installed actually have `niro upgrade --finish`?
256
+ *
257
+ * Asked rather than assumed, because the newest published version can predate this one: a machine
258
+ * running an unreleased build upgrades "up" to a package with no `upgrade` command at all. Without
259
+ * this, the hand-over fails with commander's bare `error: unknown command 'upgrade'` and the user
260
+ * is told to re-run a command that cannot exist.
261
+ */
262
+ function supportsFinish(bin) {
263
+ const probe = process.platform === "win32"
264
+ ? childProcess.spawnSync(`"${bin}" upgrade --help`, { shell: true, encoding: "utf8", stdio: "pipe" })
265
+ : childProcess.spawnSync(bin, ["upgrade", "--help"], { encoding: "utf8", stdio: "pipe" });
266
+ if (probe.error || probe.status !== 0) return false;
267
+ return String(probe.stdout || "").includes("--finish");
268
+ }
269
+
270
+ /** Phase two, running from the new package: put back what npm could not. */
271
+ async function finish(fromVersion) {
272
+ console.log(fromVersion && fromVersion !== VERSION
273
+ ? `\n niro ${fromVersion} to ${VERSION}\n`
274
+ : `\n niro ${VERSION}\n`);
275
+
276
+ // The watcher first. It is one command and cannot block, whereas a client refresh can sit on a
277
+ // prompt for as long as the user is away, and until the restart happens the watcher is running
278
+ // code from a package that no longer exists on disk.
279
+ restartWatcher();
280
+
281
+ const complete = await refreshAssistantFiles();
282
+ if (!complete) process.exitCode = EXIT_REFRESH_INCOMPLETE;
283
+
284
+ console.log(` ${color.yellow("One thing left:")} restart your AI assistant.`);
285
+ console.log(" Hooks and skills are read when it starts, so an assistant that is already open");
286
+ console.log(" is still using the old ones.\n");
287
+ }
288
+
289
+ /**
290
+ * Re-run the installer once per configured client, so their hooks, skills and instruction files
291
+ * match the package that is now installed. Returns false when anything was skipped or failed, so
292
+ * the caller can set a non-zero exit code: a script driving this needs to know that half of it
293
+ * did nothing.
294
+ *
295
+ * One client failing does not stop the others. They are separate files owned by separate tools,
296
+ * and a missing editor CLI is an ordinary reason for one to fail.
297
+ */
298
+ async function refreshAssistantFiles() {
299
+ const { home, configured } = mcpClients.configuredClients();
300
+
301
+ if (!home) {
302
+ console.log(` ${color.yellow("Skipped the assistant files:")} there is no usable home folder.`);
303
+ console.log(" HOME is unset or empty, so every path would have been read relative to the");
304
+ console.log(" folder you are standing in, which is not yours to read or write.");
305
+ console.log(` Set HOME, then run ${color.cyan("niro upgrade --finish")}.\n`);
306
+ return false;
307
+ }
308
+
309
+ if (configured.length === 0) {
310
+ console.log(" No configured assistant found, so there were no hooks or skills to refresh.");
311
+ console.log(` Connect one with ${color.cyan("niro mcp install")}.\n`);
312
+ return true;
313
+ }
314
+
315
+ if (!startCmd.hasCredentials()) {
316
+ console.log(` ${color.yellow("Heads up:")} you are not signed in, so each client's installer will ask`);
317
+ console.log(` for an API key. ${color.cyan("niro login")} first if you would rather it did not.\n`);
318
+ }
319
+
320
+ // Cursor's installer writes a project-scoped rule to <cwd>/.cursor/rules/niro.mdc, so refreshing
321
+ // it leaves a file in whatever folder the upgrade was run from. That is the installer's design,
322
+ // not something to change here, but it is a surprise when you only asked to upgrade.
323
+ if (configured.some((c) => c.id === "cursor")) {
324
+ console.log(` ${color.yellow("Note:")} refreshing Cursor also writes a project rule to`);
325
+ console.log(` ${path.join(process.cwd(), ".cursor", "rules", "niro.mdc")}`);
326
+ console.log(" because Cursor rules are per-folder. Delete it if you did not want it here.\n");
327
+ }
328
+
329
+ const total = configured.length;
330
+ console.log(` Refreshing hooks and skills for ${total} client${total === 1 ? "" : "s"}:`);
331
+ for (const client of configured) {
332
+ console.log(` ${client.name} (${client.where})`);
333
+ }
334
+ console.log("");
335
+
336
+ let done = 0;
337
+ let failed = 0;
338
+ for (const client of configured) {
339
+ console.log(color.dim(` [${done + failed + 1} of ${total}] ${client.name}`));
340
+ try {
341
+ await mcpCmd.install(["--client", client.id]);
342
+ done += 1;
343
+ } catch (err) {
344
+ failed += 1;
345
+ console.error(` ${color.yellow("!")} ${client.name} was not refreshed: ${err.message}`);
346
+ console.error(` Run ${color.cyan(`niro mcp install --client ${client.id}`)} to see why.\n`);
347
+ }
348
+ }
349
+
350
+ if (failed > 0) {
351
+ console.log(` ${done} of ${total} refreshed, ${failed} failed.`);
352
+ console.log(` Re-run ${color.cyan("niro upgrade --finish")} once the failures above are sorted.\n`);
353
+ return false;
354
+ }
355
+ console.log(` ${color.green("Done.")} ${done} of ${total} refreshed.\n`);
356
+ return true;
357
+ }
358
+
359
+ /**
360
+ * Restart the background watcher, which is a long-running process still holding the code it loaded
361
+ * when it started. Replacing files on disk cannot change a process that is already running.
362
+ */
363
+ function restartWatcher() {
364
+ if (!service.pm2Available()) {
365
+ console.log(" Background watcher: PM2 is not installed, so there is nothing to restart.\n");
366
+ return;
367
+ }
368
+
369
+ const state = service.processState();
370
+ if (state !== "online") {
371
+ const what = state === "stopped" ? "registered but not running" : "not installed";
372
+ console.log(` Background watcher: ${what}, so it was left alone.\n`);
373
+ return;
374
+ }
375
+
376
+ console.log(" Restarting the background watcher, which is still running the old code...");
377
+ if (service.restart()) {
378
+ // Deliberately not "now on <version>": pm2 re-execs the script path recorded when the daemon
379
+ // was installed (service.js records process.argv[1]), which is not necessarily the copy that
380
+ // was just upgraded. All that is known here is that pm2 restarted it.
381
+ console.log(` ${color.green("Done.")} niro-watch restarted. Check it with ${color.cyan("niro service status")}.\n`);
382
+ } else {
383
+ console.log(` ${color.yellow("!")} Could not restart it. Run ${color.cyan("niro service install")}.\n`);
384
+ }
385
+ }
386
+
387
+ module.exports = {
388
+ upgrade,
389
+ // exported for tests
390
+ refreshAssistantFiles,
391
+ restartWatcher,
392
+ describeRunningCopy,
393
+ supportsFinish,
394
+ HANDOVER_ENV,
395
+ EXIT_NPM_FAILED,
396
+ EXIT_NO_BINARY,
397
+ EXIT_HANDOVER_FAILED,
398
+ EXIT_REFRESH_INCOMPLETE,
399
+ EXIT_WRONG_COPY,
400
+ };
package/src/index.js CHANGED
@@ -309,6 +309,17 @@ function main() {
309
309
  await deleteCmd.deleteProject(projectId, { ...opts });
310
310
  }));
311
311
 
312
+ program
313
+ .command("upgrade")
314
+ .description("Upgrade niro to the latest published version, then refresh the hooks, skills and background watcher that replacing the package leaves untouched.")
315
+ .option("--finish", "Only refresh hooks, skills and the watcher; do not run npm. `niro upgrade` runs this for you in the new version once the package is replaced.")
316
+ .option("--from <version>", "The version upgraded from, for the summary line. Set automatically by `niro upgrade`.")
317
+ .option("--force", "Upgrade the global copy even when you are running a linked or local checkout, which `npm install -g` would replace.")
318
+ .action((opts) => run(async () => {
319
+ const upgradeCmd = require("./commands/upgrade");
320
+ await upgradeCmd.upgrade(opts);
321
+ }));
322
+
312
323
  const svc = program
313
324
  .command("service")
314
325
  .description("Manage the niro-watch background daemon (auto-sync on login).");
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Which AI clients on this machine already have niro registered?
3
+ *
4
+ * `niro upgrade` needs this list. The hooks, skills and instruction files an install writes are
5
+ * fixed text in the user's home folder, not read out of the package as it runs, so replacing the
6
+ * package leaves them exactly as they were. Putting them back means re-running the installer, and
7
+ * the installer configures ONE client, named with `--client`. Nothing on disk records which
8
+ * clients were ever chosen, so the list has to be worked out from what is there.
9
+ *
10
+ * Every client in the CLIENTS map is covered, because the installer leaves a file this repo owns
11
+ * in every case. Eight are registered by writing a JSON config whose path setup.js builds. The
12
+ * other six are registered by shelling out to another tool's CLI, but each one also gets a marker
13
+ * appended to a markdown file this repo writes and teardown.js later strips: ~/.claude/CLAUDE.md,
14
+ * ~/.gemini/GEMINI.md, ~/.copilot/copilot-instructions.md, ~/.factory/AGENTS.md, plus codex's
15
+ * [mcp_servers.niro] table and goose's extensions entry, both written here too. The marker is
16
+ * appended only after the registration command succeeded, so its presence means the client really
17
+ * was configured.
18
+ *
19
+ * Two rules keep this honest, and both exist because of what got the serve-time self-heal reverted
20
+ * on 2026-07-30 (d3ff3d8):
21
+ *
22
+ * Every path must be absolute. That revert's worst finding was a settings path resolving against
23
+ * the current folder and a project's own git-tracked .claude/settings.json being written. It is
24
+ * not enough to check that a home folder exists: HOME can be set to the EMPTY string while
25
+ * USERPROFILE is set, and userHome() then returns USERPROFILE and looks fine, while four of the
26
+ * path builders in setup.js read process.env.HOME directly and hand back a relative path. So the
27
+ * home is rejected unless it is absolute, and each individual path is rejected the same way.
28
+ *
29
+ * Markers are matched on a whole line, never as a substring of prose. That revert also failed on
30
+ * substring tests over prose, which captured a user's unrelated hook. A sentence mentioning the
31
+ * marker is not an install.
32
+ *
33
+ * Everything here is read-only. Nothing in this file creates, writes or deletes anything.
34
+ */
35
+ const fs = require("fs");
36
+ const path = require("path");
37
+
38
+ const setup = require("../mcp/setup");
39
+
40
+ const { CLIENTS, NIRO_MD_MARKER, userHome, gooseConfigPath } = setup;
41
+
42
+ /**
43
+ * Biggest file worth reading. None of these configs is more than a few kilobytes, and without a
44
+ * cap a config path that is a huge file, a FIFO or a symlink to /dev/zero makes `niro upgrade`
45
+ * hang or allocate without limit rather than reporting a client.
46
+ */
47
+ const MAX_CONFIG_BYTES = 4 * 1024 * 1024;
48
+
49
+ /**
50
+ * The markdown file each shell-registered client gets a niro marker appended to, written by
51
+ * setup.js after the registration command succeeds and removed by teardown.js. Relative to home.
52
+ */
53
+ const MARKER_FILES = {
54
+ "claude-code": [".claude", "CLAUDE.md"],
55
+ "gemini-cli": [".gemini", "GEMINI.md"],
56
+ "copilot-cli": [".copilot", "copilot-instructions.md"],
57
+ factory: [".factory", "AGENTS.md"],
58
+ };
59
+
60
+ /** The marker on a line of its own. A sentence that merely mentions it is not an install. */
61
+ const MARKER_LINE = new RegExp(
62
+ `^[ \\t]*${NIRO_MD_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[ \\t]*$`,
63
+ "m"
64
+ );
65
+
66
+ /**
67
+ * The home folder, or null when there is not one that can be trusted.
68
+ *
69
+ * Rejects an empty or relative home, and rejects a HOME that is set to the empty string even when
70
+ * USERPROFILE would fill in, because setup.js's claude-desktop, windsurf, vscode and cline path
71
+ * builders read process.env.HOME directly and would produce paths relative to the current folder.
72
+ */
73
+ function homeOrNull() {
74
+ if (process.env.HOME === "") return null;
75
+ const home = userHome();
76
+ return home && path.isAbsolute(home) ? home : null;
77
+ }
78
+
79
+ /** Read a config file, or null when it is missing, unreadable, not a regular file, or too big. */
80
+ function readConfig(file) {
81
+ try {
82
+ const stat = fs.statSync(file);
83
+ if (!stat.isFile()) return null; // a directory, a FIFO, a device
84
+ if (stat.size > MAX_CONFIG_BYTES) return null;
85
+ return fs.readFileSync(file, "utf-8");
86
+ } catch (err) {
87
+ return null; // missing or unreadable is not evidence of an install
88
+ }
89
+ }
90
+
91
+ /**
92
+ * True when `file` holds a real niro registration under `key`.
93
+ *
94
+ * Presence of the key is not enough. An empty object is not a working entry, and `enabled: false`
95
+ * (opencode) or `disabled: true` (cline) is a registration the user switched OFF. Reporting those
96
+ * would make `niro upgrade` re-run the installer, which rewrites the whole entry and turns niro
97
+ * back on without being asked.
98
+ */
99
+ function jsonHasNiro(file, key) {
100
+ const raw = readConfig(file);
101
+ if (raw === null) return false;
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(raw);
105
+ } catch (err) {
106
+ return false;
107
+ }
108
+ if (!parsed || typeof parsed !== "object") return false;
109
+ const section = parsed[key];
110
+ if (!section || typeof section !== "object") return false;
111
+ const entry = section.niro;
112
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
113
+ if (entry.disabled === true) return false;
114
+ if (entry.enabled === false) return false;
115
+ return Object.keys(entry).length > 0;
116
+ }
117
+
118
+ /** True when `file` exists and matches `re`. */
119
+ function fileMatches(file, re) {
120
+ const raw = readConfig(file);
121
+ return raw === null ? false : re.test(raw);
122
+ }
123
+
124
+ /**
125
+ * The JSON config paths worth checking for a client. Antigravity has two: setup.js writes to the
126
+ * legacy file only while the unified one is absent, so once both exist the builder stops returning
127
+ * the legacy path and a registration living there becomes invisible. teardown.js already checks
128
+ * both unconditionally, and the reason is written up in test/mcpClientCoverage.test.js.
129
+ */
130
+ function jsonPathsFor(id, client, home) {
131
+ if (id === "antigravity") {
132
+ return [
133
+ path.join(home, ".gemini", "antigravity", "mcp_config.json"),
134
+ path.join(home, ".gemini", "config", "mcp_config.json"),
135
+ ];
136
+ }
137
+ try {
138
+ return [client.configPath()];
139
+ } catch (err) {
140
+ return []; // a path built from an env var this platform does not set
141
+ }
142
+ }
143
+
144
+ /**
145
+ * claude-code leaves two markers and either one is proof: the niro-skill file, and the marked
146
+ * section in ~/.claude/CLAUDE.md. There is no JSON config to read, because the MCP entry itself
147
+ * goes in through `claude mcp add`.
148
+ */
149
+ function claudeCodeConfigured(home) {
150
+ const skill = path.join(home, ".claude", "skills", "niro-skill", "SKILL.md");
151
+ try {
152
+ if (fs.statSync(skill).isFile()) return skill;
153
+ } catch (err) {
154
+ // fall through to the CLAUDE.md marker
155
+ }
156
+ const memory = path.join(home, MARKER_FILES["claude-code"][0], MARKER_FILES["claude-code"][1]);
157
+ return fileMatches(memory, MARKER_LINE) ? memory : null;
158
+ }
159
+
160
+ /** codex registers the [mcp_servers.niro] table in ~/.codex/config.toml. */
161
+ function codexConfigured(home) {
162
+ const toml = path.join(home, ".codex", "config.toml");
163
+ return fileMatches(toml, /^\s*\[mcp_servers\.niro\]\s*$/m) ? toml : null;
164
+ }
165
+
166
+ /**
167
+ * goose: the same two-space-indented `niro:` line the installer writes and the uninstaller
168
+ * removes, but only when it sits under the top-level `extensions:` key. The installer's own guard
169
+ * matches that line anywhere in the file, so a `niro:` nested under some other top-level key reads
170
+ * as installed; here that would cost a wizard run that rewrites a config the user did not ask us
171
+ * to touch, so the parent key is checked as well.
172
+ */
173
+ function gooseConfigured() {
174
+ let yaml;
175
+ try {
176
+ yaml = gooseConfigPath();
177
+ } catch (err) {
178
+ return null;
179
+ }
180
+ if (!yaml || !path.isAbsolute(yaml)) return null;
181
+ const raw = readConfig(yaml);
182
+ if (raw === null) return null;
183
+
184
+ let underExtensions = false;
185
+ for (const line of raw.split(/\r?\n/)) {
186
+ if (/^\S/.test(line)) underExtensions = /^extensions:\s*$/.test(line);
187
+ else if (underExtensions && /^ {2}niro:\s*$/.test(line)) return yaml;
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /**
193
+ * Returns { home, configured }.
194
+ *
195
+ * home the home folder used, or null when there was no usable one
196
+ * configured [{ id, name, where }] for every client proved to have niro registered
197
+ */
198
+ function configuredClients() {
199
+ const home = homeOrNull();
200
+ // Without a trustworthy home every path below could resolve against the current folder, so a
201
+ // repo that happens to hold a .claude or .cursor folder would be read as the user's own
202
+ // settings and then written to. Check nothing rather than check the wrong thing.
203
+ if (!home) return { home: null, configured: [] };
204
+
205
+ const configured = [];
206
+ const add = (id, where) => {
207
+ if (!where || !path.isAbsolute(where)) return;
208
+ configured.push({ id, name: CLIENTS[id] ? CLIENTS[id].name : id, where });
209
+ };
210
+
211
+ for (const [id, client] of Object.entries(CLIENTS)) {
212
+ if (id === "claude-code") {
213
+ add(id, claudeCodeConfigured(home));
214
+ } else if (id === "codex") {
215
+ add(id, codexConfigured(home));
216
+ } else if (id === "goose") {
217
+ add(id, gooseConfigured());
218
+ } else if (client.configPath || id === "antigravity") {
219
+ const key = client.topLevelKey || "mcpServers";
220
+ for (const file of jsonPathsFor(id, client, home)) {
221
+ if (file && path.isAbsolute(file) && jsonHasNiro(file, key)) {
222
+ add(id, file);
223
+ break;
224
+ }
225
+ }
226
+ } else if (MARKER_FILES[id]) {
227
+ add(id, fileMatches(path.join(home, ...MARKER_FILES[id]), MARKER_LINE)
228
+ ? path.join(home, ...MARKER_FILES[id])
229
+ : null);
230
+ }
231
+ }
232
+
233
+ return { home, configured };
234
+ }
235
+
236
+ /** Every client id in the CLIENTS map, so a caller can prove none is silently uncovered. */
237
+ function allClientIds() {
238
+ return Object.keys(CLIENTS);
239
+ }
240
+
241
+ module.exports = { configuredClients, allClientIds, MARKER_FILES, MAX_CONFIG_BYTES };
@@ -152,6 +152,23 @@ function uninstall() {
152
152
  return true;
153
153
  }
154
154
 
155
+ /**
156
+ * Restart niro-watch so it re-execs its script and picks up a replaced package.
157
+ *
158
+ * `npm install -g` swaps the files on disk. A process that is already running keeps the code it
159
+ * loaded when it started, so after an upgrade the watcher is still on the old version until this
160
+ * runs. Returns false when PM2 is missing or the restart failed; the caller decides what to say.
161
+ */
162
+ function restart() {
163
+ if (!pm2Available()) return false;
164
+ const r = spawnSync("pm2", ["restart", PROCESS_NAME], {
165
+ shell: true,
166
+ stdio: "pipe",
167
+ encoding: "utf8",
168
+ });
169
+ return r.status === 0;
170
+ }
171
+
155
172
  /**
156
173
  * Print PM2 process status for niro-watch.
157
174
  */
@@ -226,4 +243,4 @@ function getInitSystem() {
226
243
  }
227
244
  }
228
245
 
229
- module.exports = { install, uninstall, status, processState, pm2Available, installPm2 };
246
+ module.exports = { install, uninstall, restart, status, processState, pm2Available, installPm2 };
package/src/mcp/setup.js CHANGED
@@ -2783,6 +2783,9 @@ module.exports = {
2783
2783
  addAgentsSkill,
2784
2784
  addAgentsCliSkill,
2785
2785
  CLIENTS,
2786
+ // Exported so `niro upgrade` can detect a configured Goose install by reading the same
2787
+ // file setupGoose writes, instead of rebuilding that platform-specific path a second time.
2788
+ gooseConfigPath,
2786
2789
  NIRO_MD_MARKER,
2787
2790
  NIRO_MD_SECTIONS,
2788
2791
  isNiroPostCompactHook,