@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.
@@ -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 };
@@ -82,4 +82,4 @@ function purge() {
82
82
  }
83
83
  }
84
84
 
85
- module.exports = { disabledFlagPath, isDisabled, setDisabled, purge };
85
+ module.exports = { niroHome, disabledFlagPath, isDisabled, setDisabled, purge };
@@ -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 remote URL and current branch for server-side project resolution.
154
- * Used when no project_id could be resolved locally. The server matches the remote URL
155
- * against stored project git mappings.
156
- * Returns { remote, branch } or null if not in a git repo or git is unavailable.
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
- const remote = execSync("git remote get-url origin 2>/dev/null", opts).trim();
163
- if (!remote) return null;
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();