@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 +81 -0
- package/README.md +62 -0
- package/package.json +1 -1
- package/src/commands/mcp.js +16 -7
- package/src/commands/start.js +1 -0
- package/src/commands/upgrade.js +400 -0
- package/src/index.js +11 -0
- package/src/lib/mcpClients.js +241 -0
- package/src/lib/service.js +18 -1
- package/src/mcp/setup.js +3 -0
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
package/src/commands/mcp.js
CHANGED
|
@@ -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
|
|
11
|
+
const childProcess = require("child_process");
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
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), ...
|
|
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() {
|
package/src/commands/start.js
CHANGED
|
@@ -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 };
|
package/src/lib/service.js
CHANGED
|
@@ -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,
|