@niroai/niro 0.11.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 +128 -0
- package/README.md +72 -3
- package/docs/working-on-branches.md +8 -0
- package/package.json +1 -1
- package/src/commands/_resolveProject.js +59 -3
- package/src/commands/delete.js +16 -1
- package/src/commands/mcp.js +16 -7
- package/src/commands/remove.js +13 -0
- package/src/commands/start.js +1 -0
- package/src/commands/upgrade.js +400 -0
- package/src/index.js +11 -0
- package/src/lib/folderSync.js +1 -0
- package/src/lib/impactLog.js +251 -0
- package/src/lib/mcpClients.js +241 -0
- package/src/lib/receiptStore.js +1 -1
- package/src/lib/service.js +18 -1
- package/src/mcp/server.js +30 -10
- package/src/mcp/setup.js +273 -17
- package/src/mcp/teardown.js +7 -2
|
@@ -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/receiptStore.js
CHANGED
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/server.js
CHANGED
|
@@ -52,6 +52,12 @@ function niroProjectsFilePath() {
|
|
|
52
52
|
return path.join(home, ".niro", "projects.json");
|
|
53
53
|
}
|
|
54
54
|
const { createPendingTracker, isTrackableRequest, resolveTimeoutMs } = require("../lib/pendingRequests");
|
|
55
|
+
const { createImpactRecorder } = require("../lib/impactLog");
|
|
56
|
+
|
|
57
|
+
// Evidence for the Edit|Write impact gate (docs/usecases/impact-gate.md): which impact tools this
|
|
58
|
+
// session called, and that the server is answering. Keyed by this process's working directory, which
|
|
59
|
+
// is the session folder the hook sees as `cwd` (checked on 8 live bridges, 2026-09-04).
|
|
60
|
+
const impact = createImpactRecorder({ cwd: process.cwd() });
|
|
55
61
|
|
|
56
62
|
/**
|
|
57
63
|
* This package's version, sent as X-Niro-Cli-Version so the server can tell an out-of-date install to
|
|
@@ -150,17 +156,26 @@ function resolveProjectIdFromNiroProjects() {
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
/**
|
|
153
|
-
* Step 3: Detect the git
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
159
|
+
* Step 3: Detect the current git branch, HEAD and dirty state — plus the origin remote when there is
|
|
160
|
+
* one — for the freshness/takeover notices, server-side project resolution, and per-branch temp routing.
|
|
161
|
+
* The gate is HEAD (is this a git repo with a commit?), NOT origin: a local repo with commits but no
|
|
162
|
+
* origin remote (a `local://` niro project, or a repo not yet pushed) still has a branch that drives
|
|
163
|
+
* routing, so its git state must be reported with a null remote rather than dropped entirely.
|
|
164
|
+
* Returns { remote (nullable), branch, commit, dirty } or null if not a git repo / no commit / git unavailable.
|
|
157
165
|
*/
|
|
158
166
|
function detectGitContext(dir) {
|
|
159
167
|
try {
|
|
160
168
|
const opts = { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] };
|
|
161
169
|
if (dir) opts.cwd = dir;
|
|
162
|
-
|
|
163
|
-
|
|
170
|
+
// HEAD first: this is the "real git repo with a commit?" test. The old `if (!remote) return null`
|
|
171
|
+
// bailed on a MISSING ORIGIN and so discarded the branch + commit of every no-origin repo — those
|
|
172
|
+
// repos then sent no _niroGit and never routed to their temp project. The owned-temp match needs
|
|
173
|
+
// only the branch, not the remote, so keep going and let the remote be null.
|
|
174
|
+
let commit = null;
|
|
175
|
+
try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
|
|
176
|
+
if (!commit) return null; // not a git repo, or a repo with no commit yet — nothing to report
|
|
177
|
+
let remote = null;
|
|
178
|
+
try { remote = execSync("git remote get-url origin 2>/dev/null", opts).trim() || null; } catch (_) {}
|
|
164
179
|
// normalizeBranch, not a bare trim: on a detached HEAD this command PRINTS "HEAD" instead of
|
|
165
180
|
// failing, and sending that verbatim told the server the checkout was on a branch called "HEAD".
|
|
166
181
|
// Nothing indexes such a branch, so the routing ladder read every bisect / tag checkout as
|
|
@@ -168,10 +183,6 @@ function detectGitContext(dir) {
|
|
|
168
183
|
// a null branch. `git.inspect` has always collapsed "HEAD" to null; this shares that one rule.
|
|
169
184
|
let branch = null;
|
|
170
185
|
try { branch = normalizeBranch(execSync("git rev-parse --abbrev-ref HEAD 2>/dev/null", opts)); } catch (_) {}
|
|
171
|
-
// The freshness/takeover notices compare the local HEAD + dirty state against what Niro indexed, so
|
|
172
|
-
// the bridge must report them — without git_commit the server-side check no-ops entirely.
|
|
173
|
-
let commit = null;
|
|
174
|
-
try { commit = execSync("git rev-parse HEAD 2>/dev/null", opts).trim(); } catch (_) {}
|
|
175
186
|
let dirty = false;
|
|
176
187
|
try { dirty = execSync("git status --porcelain 2>/dev/null", opts).trim().length > 0; } catch (_) {}
|
|
177
188
|
return { remote, branch, commit, dirty };
|
|
@@ -535,6 +546,8 @@ function connectSse() {
|
|
|
535
546
|
} else {
|
|
536
547
|
// Cancel the client-side deadline when a delivered result matches an in-flight request.
|
|
537
548
|
pending.onIncoming(msg);
|
|
549
|
+
impact.noteResult(msg);
|
|
550
|
+
impact.touchAlive();
|
|
538
551
|
rewritten = captureReceipt(msg);
|
|
539
552
|
}
|
|
540
553
|
} catch (_) { /* not correlatable JSON — forward unchanged */ }
|
|
@@ -827,6 +840,7 @@ async function main() {
|
|
|
827
840
|
// Remember the host's initialize so we can replay it verbatim to re-authenticate a fresh
|
|
828
841
|
// session after a transparent reconnect.
|
|
829
842
|
if (message.method === "initialize") initializeRaw = line;
|
|
843
|
+
impact.noteRequest(message);
|
|
830
844
|
|
|
831
845
|
// Arm the deadline for a real request before sending; a delivered result (or the
|
|
832
846
|
// send-failure path below) cancels it.
|
|
@@ -836,6 +850,9 @@ async function main() {
|
|
|
836
850
|
await sendMessage(message);
|
|
837
851
|
} catch (err) {
|
|
838
852
|
process.stderr.write(`[niro-mcp] Failed to send message: ${err.message}\n`);
|
|
853
|
+
// The attempt still counts for the impact gate: a server that cannot be reached must not leave
|
|
854
|
+
// the file locked until the alive marker ages out (review finding, 2026-09-05).
|
|
855
|
+
impact.noteResult({ jsonrpc: "2.0", id: message.id, error: { code: -32001, message: err.message } });
|
|
839
856
|
// The POST never reached the server, so no result will arrive: fail this request fast
|
|
840
857
|
// instead of waiting out the deadline.
|
|
841
858
|
if (trackable) {
|
|
@@ -852,6 +869,9 @@ async function main() {
|
|
|
852
869
|
rl.on("close", () => {
|
|
853
870
|
process.exit(0);
|
|
854
871
|
});
|
|
872
|
+
// A bridge that is gone must not keep the impact gate denying edits for up to 30 minutes on the
|
|
873
|
+
// strength of a stale alive marker. A hard kill still leaves it; the hook ages it out.
|
|
874
|
+
process.on("exit", () => impact.clearAlive());
|
|
855
875
|
}
|
|
856
876
|
|
|
857
877
|
main();
|