@niroai/niro 0.4.0 → 0.5.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 +44 -0
- package/package.json +1 -1
- package/src/commands/mcp.js +29 -1
- package/src/index.js +6 -0
- package/src/lib/receiptStore.js +85 -0
- package/src/mcp/server.js +56 -1
- package/src/mcp/setup.js +251 -26
- package/src/mcp/teardown.js +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,8 +18,52 @@ startup.
|
|
|
18
18
|
|
|
19
19
|
Nothing else is required.
|
|
20
20
|
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **You can now see what Niro did, without asking.** At the end of a task you get one line:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
[NIRO] Mapped 29 callers across 8 files that a grep would have missed · 3 calls instead of ~14 searches
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The call count is measured by the server. The number of searches it stands in for, and the
|
|
30
|
+
description, are the assistant's own account of its own work, so that number always carries a `~`
|
|
31
|
+
to mark it as an estimate. We would rather you trusted a smaller number than doubted a bigger one.
|
|
32
|
+
|
|
33
|
+
On a turn where Niro found nothing useful, the description is dropped rather than invented, and
|
|
34
|
+
you see the bare call count.
|
|
35
|
+
|
|
36
|
+
- **It also tells you when Niro went unused.** On a turn where the assistant searched your code
|
|
37
|
+
without asking Niro, you get `[NIRO] Not consulted: this turn used grep and file reads instead`.
|
|
38
|
+
Until now a skipped turn looked exactly like Niro not being installed, which meant every one of
|
|
39
|
+
our failures was invisible. Nothing is printed after a turn that did no code work.
|
|
40
|
+
|
|
41
|
+
- **On by default, including on upgrade.** Turn it off with `niro mcp receipt --off` and back on
|
|
42
|
+
with `--on`; run it with no flags to see the current state. The setting is per machine.
|
|
43
|
+
|
|
44
|
+
Works the same in all twelve supported assistants: the line travels back inside the
|
|
45
|
+
`mark_task_complete` tool result and your assistant prints it, wrapped in backticks so it renders
|
|
46
|
+
as code rather than as more of its own prose. If the assistant never calls `mark_task_complete`,
|
|
47
|
+
no line appears.
|
|
48
|
+
|
|
21
49
|
### Fixed
|
|
22
50
|
|
|
51
|
+
- **Codex no longer asks permission for every Niro tool call.** Two causes, both fixed by re-running
|
|
52
|
+
`niro mcp install`:
|
|
53
|
+
|
|
54
|
+
The approval mode was set to `"auto"`, which asks Codex to decide for itself — and it decides to
|
|
55
|
+
ask. It is now `"writes"`: prompt only for tools that write, and Niro writes nothing. An existing
|
|
56
|
+
`"auto"` is migrated; a value you chose yourself is left alone.
|
|
57
|
+
|
|
58
|
+
Codex also records a per-tool override whenever you answer a prompt with "approve once" rather than
|
|
59
|
+
"always allow", and a per-tool setting beats the default. Those are now cleared on install.
|
|
60
|
+
|
|
61
|
+
- **A malformed tool response when a reply contained a backslash, tab or quote.** The
|
|
62
|
+
`mark_task_complete` response was assembled by a hand-written JSON builder that escaped only the
|
|
63
|
+
double quote. That was survivable while every value was a fixed keyword, but any free text
|
|
64
|
+
containing a Windows path or a tab produced JSON the client could not parse, losing the whole
|
|
65
|
+
response. All control characters are now escaped.
|
|
66
|
+
|
|
23
67
|
- **The reminder to prefer niro's tools no longer goes quiet mid-session.** Claude Code's
|
|
24
68
|
`Grep|Glob|Read` hook deduplicated to once per session, per repo, per branch — so it fired early
|
|
25
69
|
in a session and then said nothing, which is precisely when an agent drifts back to `grep`. It now
|
package/package.json
CHANGED
package/src/commands/mcp.js
CHANGED
|
@@ -37,4 +37,32 @@ function serve() {
|
|
|
37
37
|
require("../mcp/server");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `niro mcp receipt` — the per-developer off switch for the end-of-task line.
|
|
42
|
+
*
|
|
43
|
+
* The line itself is printed by the model from the tool result; nothing here displays it. This
|
|
44
|
+
* command only records the preference, which the bridge reads to decide whether to strip the
|
|
45
|
+
* receipt before the model ever sees it.
|
|
46
|
+
*
|
|
47
|
+
* `--pop` and `--hook` are gone. They served the Claude Code Stop hook, which was retired because
|
|
48
|
+
* Claude Code labels hook output "Stop says: …" — a system-warning framing for what is meant to be
|
|
49
|
+
* a quiet note from Niro.
|
|
50
|
+
*/
|
|
51
|
+
function receipt(opts = {}) {
|
|
52
|
+
const store = require("../lib/receiptStore");
|
|
53
|
+
|
|
54
|
+
if (opts.off || opts.on) {
|
|
55
|
+
const changed = store.setDisabled(!!opts.off);
|
|
56
|
+
const state = opts.off ? "off" : "on";
|
|
57
|
+
console.log(changed
|
|
58
|
+
? ` End-of-task Niro line is now ${state}.`
|
|
59
|
+
: ` End-of-task Niro line was already ${state}.`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(store.isDisabled()
|
|
64
|
+
? " End-of-task Niro line: off (turn it on with `niro mcp receipt --on`)"
|
|
65
|
+
: " End-of-task Niro line: on (turn it off with `niro mcp receipt --off`)");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { install, uninstall, serve, receipt };
|
package/src/index.js
CHANGED
|
@@ -265,6 +265,12 @@ function main() {
|
|
|
265
265
|
.command("serve")
|
|
266
266
|
.description("Run the Niro MCP server over stdio (used by your editor's MCP config).")
|
|
267
267
|
.action(() => { require("./commands/mcp").serve(); });
|
|
268
|
+
mcp
|
|
269
|
+
.command("receipt")
|
|
270
|
+
.description("Turn the end-of-task Niro line on or off for this machine. No flags shows the current state.")
|
|
271
|
+
.option("--on", "Turn the end-of-task line back on.")
|
|
272
|
+
.option("--off", "Stop showing the end-of-task line.")
|
|
273
|
+
.action((opts) => { require("./commands/mcp").receipt(opts); });
|
|
268
274
|
|
|
269
275
|
program.parseAsync(process.argv).catch((err) => {
|
|
270
276
|
console.error(err && err.message ? err.message : err);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-developer off switch for the end-of-task receipt line.
|
|
3
|
+
*
|
|
4
|
+
* The line itself is composed by the server and travels back inside the mark_task_complete tool
|
|
5
|
+
* result, which the model prints wrapped in backticks. Nothing is stored locally to display it.
|
|
6
|
+
*
|
|
7
|
+
* This file used to be a letterbox: the bridge wrote each receipt to disk and a Claude Code Stop
|
|
8
|
+
* hook printed it. That worked, but Claude Code prefixes anything a Stop hook prints with
|
|
9
|
+
* "Stop says: …", which read as a system warning rather than a note from Niro. Printing from the
|
|
10
|
+
* model reads better and works in all twelve clients instead of one, so the hook, the letterbox and
|
|
11
|
+
* the terminal colouring all went with it.
|
|
12
|
+
*
|
|
13
|
+
* What remains is the switch. It stays local because it is a personal preference: the server serves
|
|
14
|
+
* a whole account and cannot know that one developer wants the line quiet. The bridge reads it and
|
|
15
|
+
* strips the receipt from the tool result, so the model never sees it and cannot print it.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
/** ~/.niro, resolved the same way as the rest of the CLI (Windows has no HOME). */
|
|
22
|
+
function niroHome() {
|
|
23
|
+
const home = process.env.HOME || process.env.USERPROFILE || os.homedir() || "";
|
|
24
|
+
return path.join(home, ".niro");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Presence of this file switches the line off.
|
|
29
|
+
*
|
|
30
|
+
* A file rather than a key inside projects.json so that toggling it can never corrupt, or be
|
|
31
|
+
* corrupted by, the sync state the watch daemon depends on.
|
|
32
|
+
*/
|
|
33
|
+
function disabledFlagPath() {
|
|
34
|
+
return path.join(niroHome(), "receipts-off");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isDisabled() {
|
|
38
|
+
try {
|
|
39
|
+
return fs.existsSync(disabledFlagPath());
|
|
40
|
+
} catch {
|
|
41
|
+
// Cannot read the home directory: treat as ON. Failing to a silent state would make the
|
|
42
|
+
// feature look broken with nothing to explain why.
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Turn the line on or off. Returns true when the state actually changed. */
|
|
48
|
+
function setDisabled(disabled) {
|
|
49
|
+
const flag = disabledFlagPath();
|
|
50
|
+
try {
|
|
51
|
+
if (disabled) {
|
|
52
|
+
if (fs.existsSync(flag)) return false;
|
|
53
|
+
fs.mkdirSync(path.dirname(flag), { recursive: true, mode: 0o700 });
|
|
54
|
+
fs.writeFileSync(flag, "", { mode: 0o600 });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (!fs.existsSync(flag)) return false;
|
|
58
|
+
fs.unlinkSync(flag);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove the off switch and any leftovers from the retired letterbox.
|
|
67
|
+
*
|
|
68
|
+
* Used by `niro mcp uninstall`, and worth keeping past the letterbox itself: anyone who ran a
|
|
69
|
+
* version that wrote receipts to disk still has that directory, and an uninstall that leaves files
|
|
70
|
+
* behind is not an uninstall.
|
|
71
|
+
*/
|
|
72
|
+
function purge() {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(path.join(niroHome(), "receipts"), { recursive: true, force: true });
|
|
75
|
+
} catch {
|
|
76
|
+
/* best effort */
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(disabledFlagPath(), { force: true });
|
|
80
|
+
} catch {
|
|
81
|
+
/* best effort */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { disabledFlagPath, isDisabled, setDisabled, purge };
|
package/src/mcp/server.js
CHANGED
|
@@ -42,6 +42,7 @@ const readline = require("readline");
|
|
|
42
42
|
const { execSync } = require("child_process");
|
|
43
43
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
44
44
|
const { redactRemoteCreds } = require("../lib/git");
|
|
45
|
+
const receiptStore = require("../lib/receiptStore");
|
|
45
46
|
|
|
46
47
|
/** Absolute path to ~/.niro/projects.json. Uses os.homedir() so it also resolves on Windows, where HOME is
|
|
47
48
|
* normally unset — `process.env.HOME || ""` there produced a relative path that never existed, silently
|
|
@@ -507,6 +508,7 @@ function connectSse() {
|
|
|
507
508
|
// Swallow the response to an internally-replayed initialize (sentinel id) so the host
|
|
508
509
|
// never sees a duplicate initialize result after a transparent reconnect.
|
|
509
510
|
let swallowed = false;
|
|
511
|
+
let rewritten = null;
|
|
510
512
|
try {
|
|
511
513
|
const msg = JSON.parse(data);
|
|
512
514
|
// ADR-018 handshake: only an initialize result carries capabilities. When the server
|
|
@@ -528,9 +530,12 @@ function connectSse() {
|
|
|
528
530
|
} else {
|
|
529
531
|
// Cancel the client-side deadline when a delivered result matches an in-flight request.
|
|
530
532
|
pending.onIncoming(msg);
|
|
533
|
+
rewritten = captureReceipt(msg);
|
|
531
534
|
}
|
|
532
535
|
} catch (_) { /* not correlatable JSON — forward unchanged */ }
|
|
533
|
-
|
|
536
|
+
// captureReceipt returns a rewritten payload when it stripped the receipt field so the
|
|
537
|
+
// Stop hook can print it instead; otherwise the original bytes go through untouched.
|
|
538
|
+
if (!swallowed) process.stdout.write((rewritten || data) + "\n");
|
|
534
539
|
}
|
|
535
540
|
}
|
|
536
541
|
});
|
|
@@ -662,6 +667,56 @@ function sendMessage(message) {
|
|
|
662
667
|
});
|
|
663
668
|
}
|
|
664
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Stash the end-of-task receipt line, if this incoming message carries one.
|
|
672
|
+
*
|
|
673
|
+
* The server composes the line and returns it inside the mark_task_complete tool result. The model
|
|
674
|
+
* is asked to print it, but a model can forget an instruction; the Claude Code Stop hook prints it
|
|
675
|
+
* whether or not the model cooperates, and this is where it gets the text. See
|
|
676
|
+
* `docs/usecases/task-receipt.md`.
|
|
677
|
+
*
|
|
678
|
+
* Deliberately does NOT filter by tool name: the bridge does not track which request id belonged to
|
|
679
|
+
* which tool, and a `receipt` string in a result is unambiguous enough on its own. Any future tool
|
|
680
|
+
* that wants to say something at end of task gets this for free.
|
|
681
|
+
*
|
|
682
|
+
* Silent on every failure. This sits on the path that hands results back to the developer's
|
|
683
|
+
* assistant, so a malformed message, an unwritable home directory, or an unexpected shape must cost
|
|
684
|
+
* the receipt and nothing else.
|
|
685
|
+
*/
|
|
686
|
+
function captureReceipt(msg) {
|
|
687
|
+
// Nothing to do unless the developer has switched the line off. The receipt is displayed by the
|
|
688
|
+
// model printing it from the tool result — there is no local copy and no hook any more.
|
|
689
|
+
if (!receiptStore.isDisabled()) return null;
|
|
690
|
+
try {
|
|
691
|
+
const content = msg && msg.result && msg.result.content;
|
|
692
|
+
if (!Array.isArray(content)) return null;
|
|
693
|
+
for (const item of content) {
|
|
694
|
+
if (!item || typeof item.text !== "string") continue;
|
|
695
|
+
// Cheap reject before parsing: tool results are frequently large, and almost none of them
|
|
696
|
+
// are the one message per task that carries a receipt.
|
|
697
|
+
if (!item.text.includes('"receipt"')) continue;
|
|
698
|
+
let parsed;
|
|
699
|
+
try {
|
|
700
|
+
parsed = JSON.parse(item.text);
|
|
701
|
+
} catch (_) {
|
|
702
|
+
continue; // not JSON — some other tool's prose that happens to contain the word
|
|
703
|
+
}
|
|
704
|
+
if (parsed && typeof parsed.receipt === "string") {
|
|
705
|
+
// Strip it here rather than asking the server not to send it. The switch is per developer
|
|
706
|
+
// and lives on this machine; the server serves a whole account and cannot know that one
|
|
707
|
+
// person wants it quiet.
|
|
708
|
+
delete parsed.receipt;
|
|
709
|
+
item.text = JSON.stringify(parsed);
|
|
710
|
+
return JSON.stringify(msg);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} catch (_) {
|
|
714
|
+
/* never let this disturb the message path */
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
|
|
665
720
|
/**
|
|
666
721
|
* Enrich an outgoing tools/call: send the folder's `.niro-project` pin as `_niroPin` (ADR-018 — a
|
|
667
722
|
* SOURCE binding the server's routing ladder consumes, NOT an explicit project choice), and
|
package/src/mcp/setup.js
CHANGED
|
@@ -127,6 +127,17 @@ function isNiroPreToolUseHook(h) {
|
|
|
127
127
|
return cmds.some((c) => c.includes("should_create_temp_project") || c.includes("should_sandbox_repo"));
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* True if `h` is niro's Stop hook (the end-of-task receipt). Keyed on the exact subcommand it runs,
|
|
132
|
+
* which no other tool would have a reason to invoke. Shared by installer and uninstaller.
|
|
133
|
+
*/
|
|
134
|
+
function isNiroStopHook(h) {
|
|
135
|
+
const cmds = [];
|
|
136
|
+
if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
|
|
137
|
+
if (h && typeof h.command === "string") cmds.push(h.command);
|
|
138
|
+
return cmds.some((c) => c.includes("mcp receipt --hook"));
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
/**
|
|
131
142
|
* True if `h` is niro's PreToolUse explore-first reminder entry (matcher Grep|Glob|Read). Keyed on the
|
|
132
143
|
* unique `niro-explore-` marker-file prefix so a foreign PreToolUse hook on the same matcher is never
|
|
@@ -565,6 +576,7 @@ async function setupClaudeCode(apiKey, mcpServerUrl) {
|
|
|
565
576
|
addClaudeCodeUserPromptHook();
|
|
566
577
|
addClaudeCodePreToolUseHook();
|
|
567
578
|
addClaudeCodeExploreHook();
|
|
579
|
+
removeStaleNiroStopHook();
|
|
568
580
|
addClaudeCodeMemory();
|
|
569
581
|
addClaudeCodeSkill();
|
|
570
582
|
addClaudeCodeCliSkill();
|
|
@@ -769,6 +781,47 @@ const NIRO_PRETOOL_COMMAND = [
|
|
|
769
781
|
`printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"Niro temporary project check: you are editing a Niro-indexed repo on its CURRENT branch. The temporary project answer is per branch, so a check you ran on a different branch (e.g. before switching) does NOT count here. Call should_create_temp_project(git_url, branch=current branch, project_id from its .niro-project) for THIS repo now and run niro new-temp-project if it returns needs_temp_project. Shown once per repo per branch per session."}}'`,
|
|
770
782
|
].join('; ');
|
|
771
783
|
|
|
784
|
+
/**
|
|
785
|
+
* Removes a Stop hook left behind by an earlier version of this installer.
|
|
786
|
+
*
|
|
787
|
+
* Niro used to print the end-of-task receipt from a Claude Code Stop hook. It worked, but Claude
|
|
788
|
+
* Code labels anything a Stop hook prints with "Stop says: …", which read as a confusing system
|
|
789
|
+
* warning rather than a note from Niro. The receipt is now printed by the model itself, wrapped in
|
|
790
|
+
* backticks, which renders as code in every client rather than just this one.
|
|
791
|
+
*
|
|
792
|
+
* Anyone who installed the hook version still has it on disk, and hooks are frozen there until an
|
|
793
|
+
* install re-runs — so a plain upgrade would leave them seeing "Stop says:" for ever. Hence removing
|
|
794
|
+
* it here rather than only in uninstall. Other Stop hooks, including the developer's own, are
|
|
795
|
+
* matched by our exact subcommand and never touched.
|
|
796
|
+
*/
|
|
797
|
+
function removeStaleNiroStopHook() {
|
|
798
|
+
const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
|
|
799
|
+
let settings;
|
|
800
|
+
try {
|
|
801
|
+
if (!fs.existsSync(settingsPath)) return;
|
|
802
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
803
|
+
} catch {
|
|
804
|
+
return; // unreadable or malformed — leave it alone rather than risk rewriting it
|
|
805
|
+
}
|
|
806
|
+
const stop = settings.hooks && settings.hooks.Stop;
|
|
807
|
+
if (!Array.isArray(stop)) return;
|
|
808
|
+
|
|
809
|
+
const kept = stop.filter((h) => !isNiroStopHook(h));
|
|
810
|
+
if (kept.length === stop.length) return;
|
|
811
|
+
|
|
812
|
+
settings.hooks.Stop = kept;
|
|
813
|
+
if (kept.length === 0) delete settings.hooks.Stop;
|
|
814
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
815
|
+
|
|
816
|
+
try {
|
|
817
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
818
|
+
console.log(" Removed the old Stop hook — the end-of-task line now comes from Niro directly,");
|
|
819
|
+
console.log(" without the \"Stop says:\" prefix Claude Code adds to hook output.\n");
|
|
820
|
+
} catch (err) {
|
|
821
|
+
console.warn(` Could not update ~/.claude/settings.json: ${err.message}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
772
825
|
/**
|
|
773
826
|
* Adds the PreToolUse (Edit|Write) temporary project-reminder hook to ~/.claude/settings.json. Idempotent:
|
|
774
827
|
* upgrades a stale niro entry in place, never touches a foreign PreToolUse hook (matched by the
|
|
@@ -1427,53 +1480,222 @@ async function setupCodex(apiKey, mcpServerUrl) {
|
|
|
1427
1480
|
|
|
1428
1481
|
/**
|
|
1429
1482
|
* After `codex mcp add` writes [mcp_servers.niro], set
|
|
1430
|
-
* `default_tools_approval_mode = "
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
1483
|
+
* `default_tools_approval_mode = "writes"` — prompt only for tools that write, and niro writes
|
|
1484
|
+
* nothing, so nothing prompts.
|
|
1485
|
+
*
|
|
1486
|
+
* It was "auto" until a developer reported being asked to approve every single call. Codex accepts
|
|
1487
|
+
* four values (`auto`, `prompt`, `writes`, `approve` — confirmed from its own validation error on a
|
|
1488
|
+
* bad one), and "auto" means Codex decides for itself. It decides to ask. "writes" states the fact
|
|
1489
|
+
* about niro that makes asking unnecessary, rather than delegating the judgement.
|
|
1490
|
+
*
|
|
1491
|
+
* Inserted directly after the table header, which is valid TOML — bare keys precede any sub-table.
|
|
1492
|
+
* Idempotent and a safe no-op if the file or table is missing. Also prints the optional
|
|
1493
|
+
* `required = true` recommendation (left off by default: it would make niro a hard Codex startup
|
|
1494
|
+
* dependency).
|
|
1435
1495
|
*/
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1496
|
+
/**
|
|
1497
|
+
* Write ~/.codex/config.toml, then prove Codex can still load it — rolling back if it cannot.
|
|
1498
|
+
*
|
|
1499
|
+
* This file holds [mcp_servers.niro] itself, so a malformed edit does not merely fail: it makes
|
|
1500
|
+
* Codex reject the whole file and takes the developer's Niro registration down with it. That is the
|
|
1501
|
+
* failure mode that got a Codex hook built and pulled twice (ae7bdaa, dcc4f7f), and the lesson from
|
|
1502
|
+
* it was that every write here needs a verify-and-rollback rather than care alone.
|
|
1503
|
+
*
|
|
1504
|
+
* Atomic temp-then-rename, with the original file mode preserved. Widening permissions matters:
|
|
1505
|
+
* this file can hold API keys, and an earlier version of the pulled work reset 0600 to 0644 on
|
|
1506
|
+
* rollback.
|
|
1507
|
+
*
|
|
1508
|
+
* Verification runs the real binary. `codex mcp get niro` parses the config and prints the server,
|
|
1509
|
+
* so a non-zero exit means Codex would not load what we just wrote. If the binary is missing we
|
|
1510
|
+
* cannot verify, and the write is skipped entirely — an unverifiable edit to this file is not worth
|
|
1511
|
+
* the risk it carries.
|
|
1512
|
+
*/
|
|
1513
|
+
function writeCodexConfigVerified(cfgPath, contents) {
|
|
1514
|
+
const { execSync } = require("child_process");
|
|
1515
|
+
|
|
1516
|
+
try {
|
|
1517
|
+
execSync("command -v codex >/dev/null 2>&1", { stdio: "ignore", shell: "/bin/sh" });
|
|
1518
|
+
} catch {
|
|
1519
|
+
throw new Error("codex is not on PATH, so the edit cannot be verified — leaving the file alone");
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const previous = fs.readFileSync(cfgPath, "utf-8");
|
|
1523
|
+
let mode = 0o600;
|
|
1524
|
+
try {
|
|
1525
|
+
mode = fs.statSync(cfgPath).mode & 0o777;
|
|
1526
|
+
} catch {
|
|
1527
|
+
/* fall back to the conservative default above */
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const tmp = `${cfgPath}.${process.pid}.tmp`;
|
|
1531
|
+
fs.writeFileSync(tmp, contents, { mode });
|
|
1532
|
+
fs.renameSync(tmp, cfgPath);
|
|
1439
1533
|
|
|
1534
|
+
try {
|
|
1535
|
+
execSync("codex mcp get niro", { stdio: "ignore", timeout: 15000 });
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
// Put it back exactly as it was, mode included, then report.
|
|
1538
|
+
const rollback = `${cfgPath}.${process.pid}.rollback`;
|
|
1539
|
+
fs.writeFileSync(rollback, previous, { mode });
|
|
1540
|
+
fs.renameSync(rollback, cfgPath);
|
|
1541
|
+
throw new Error("Codex could not load the edited config — rolled back, nothing changed");
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Remove [mcp_servers.niro.tools.*] approval overrides from ~/.codex/config.toml.
|
|
1547
|
+
*
|
|
1548
|
+
* Codex writes one of these each time a developer answers a tool prompt with "approve once", and a
|
|
1549
|
+
* per-tool setting overrides default_tools_approval_mode. The result is a config that looks correctly
|
|
1550
|
+
* configured and prompts on every single call.
|
|
1551
|
+
*
|
|
1552
|
+
* Only niro's own sub-tables are touched, matched by their full prefix. This is the file that holds
|
|
1553
|
+
* [mcp_servers.niro] itself, so a careless edit costs the developer their whole Niro registration —
|
|
1554
|
+
* the reason a Codex hook was built and pulled twice (ae7bdaa, dcc4f7f). Hence: read, rewrite in
|
|
1555
|
+
* memory, and only write when the result still parses as something Codex will load.
|
|
1556
|
+
*
|
|
1557
|
+
* Returns how many tables were removed; 0 on any failure, having changed nothing.
|
|
1558
|
+
*/
|
|
1559
|
+
function clearCodexPerToolApprovals(cfgPath) {
|
|
1440
1560
|
let toml;
|
|
1441
1561
|
try {
|
|
1442
1562
|
toml = fs.readFileSync(cfgPath, "utf-8");
|
|
1443
1563
|
} catch {
|
|
1444
|
-
|
|
1445
|
-
return;
|
|
1564
|
+
return 0;
|
|
1446
1565
|
}
|
|
1447
1566
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1567
|
+
// A table runs from its header to the next header at any level, or end of file.
|
|
1568
|
+
const pattern = /\n\[mcp_servers\.niro\.tools\.[^\]]+\]\n(?:(?!\n?\[)[^\n]*\n)*/g;
|
|
1569
|
+
const matches = toml.match(pattern);
|
|
1570
|
+
if (!matches || matches.length === 0) return 0;
|
|
1571
|
+
|
|
1572
|
+
const rewritten = toml.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n");
|
|
1573
|
+
|
|
1574
|
+
// Refuse to write anything that lost the niro server itself. Cheap, and the failure it guards
|
|
1575
|
+
// against is the one that matters.
|
|
1576
|
+
if (!rewritten.includes("[mcp_servers.niro]")) {
|
|
1577
|
+
console.warn(" Skipped clearing Codex tool approvals — the edit would have dropped [mcp_servers.niro].\n");
|
|
1578
|
+
return 0;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
try {
|
|
1582
|
+
writeCodexConfigVerified(cfgPath, rewritten);
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
console.warn(` Could not clear Codex per-tool approvals: ${err.message}\n`);
|
|
1585
|
+
return 0;
|
|
1452
1586
|
}
|
|
1453
1587
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1588
|
+
console.log(` Cleared ${matches.length} per-tool approval override${matches.length === 1 ? "" : "s"} for niro.`);
|
|
1589
|
+
console.log(" → those beat default_tools_approval_mode and made every call prompt.\n");
|
|
1590
|
+
return matches.length;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Absolute path to Codex's config.toml, honouring CODEX_HOME.
|
|
1595
|
+
*
|
|
1596
|
+
* `codex mcp add` writes wherever CODEX_HOME points, so reading from a hard-coded ~/.codex would
|
|
1597
|
+
* edit a file Codex never loads while reporting success — the server registered in one place and the
|
|
1598
|
+
* approval mode set in another.
|
|
1599
|
+
*
|
|
1600
|
+
* Returns null rather than a relative path when no home resolves. userHome() can be "", and
|
|
1601
|
+
* path.join("", ".codex", "config.toml") yields ".codex/config.toml" relative to the current working
|
|
1602
|
+
* directory, which on some machines is a git-tracked project folder.
|
|
1603
|
+
*/
|
|
1604
|
+
function codexConfigPath() {
|
|
1605
|
+
const codexHome = process.env.CODEX_HOME && process.env.CODEX_HOME.trim();
|
|
1606
|
+
if (codexHome) return path.join(codexHome, "config.toml");
|
|
1607
|
+
const home = userHome();
|
|
1608
|
+
if (!home) return null;
|
|
1609
|
+
return path.join(home, ".codex", "config.toml");
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function hardenCodexServerConfig() {
|
|
1613
|
+
const cfgPath = codexConfigPath();
|
|
1614
|
+
if (!cfgPath) {
|
|
1615
|
+
console.warn(" Could not resolve ~/.codex/config.toml — skipping approval-mode hardening.\n");
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1458
1618
|
|
|
1459
1619
|
console.log(" Tip: for a hard dependency, add `required = true` under [mcp_servers.niro]");
|
|
1460
1620
|
console.log(" (Codex then fails startup if niro is unreachable — off by default).\n");
|
|
1461
1621
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1622
|
+
// Clear per-tool overrides FIRST. Codex writes a [mcp_servers.niro.tools.<name>] table with
|
|
1623
|
+
// approval_mode = "approve" whenever the developer answers a prompt with "approve once" rather
|
|
1624
|
+
// than "always allow", and a per-tool setting BEATS default_tools_approval_mode. So the default
|
|
1625
|
+
// below can be correct while every call still prompts — exactly what a developer reported.
|
|
1626
|
+
//
|
|
1627
|
+
// This rewrites the file, so everything after it must work from freshly read contents. An earlier
|
|
1628
|
+
// version computed byte offsets BEFORE this call and spliced with them afterwards, which inserted
|
|
1629
|
+
// text into the middle of an unrelated line whenever the rewrite shortened anything above the niro
|
|
1630
|
+
// header. That produced invalid TOML, and Codex refuses to load a config it cannot parse — taking
|
|
1631
|
+
// [mcp_servers.niro] and every other server's credentials down with it.
|
|
1632
|
+
clearCodexPerToolApprovals(cfgPath);
|
|
1633
|
+
|
|
1634
|
+
const located = locateCodexNiroTable(cfgPath);
|
|
1635
|
+
if (!located) {
|
|
1636
|
+
console.warn(" [mcp_servers.niro] not found in config.toml — skipping approval-mode hardening.\n");
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const { toml, bodyStart, bodyEnd, body } = located;
|
|
1640
|
+
|
|
1641
|
+
const existing = body.match(/^[ \t]*default_tools_approval_mode[ \t]*=[ \t]*"([^"]*)"/m);
|
|
1642
|
+
|
|
1643
|
+
if (existing && existing[1] !== "auto") {
|
|
1644
|
+
console.log(` Codex approval mode already set to "${existing[1]}" — leaving your choice alone.\n`);
|
|
1464
1645
|
return;
|
|
1465
1646
|
}
|
|
1466
1647
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1648
|
+
// Every edit below is confined to niro's own table slice. Replacing across the whole file with a
|
|
1649
|
+
// single /m regex rewrote the FIRST matching line anywhere in it — which, since `codex mcp add`
|
|
1650
|
+
// appends niro last, was reliably ANOTHER server's line. That silently relaxed a third party's
|
|
1651
|
+
// approval settings and left niro's untouched, while printing that it had succeeded.
|
|
1652
|
+
let newBody;
|
|
1653
|
+
let message;
|
|
1654
|
+
if (existing) {
|
|
1655
|
+
newBody = body.replace(/^([ \t]*default_tools_approval_mode[ \t]*=[ \t]*)"auto"/m, '$1"writes"');
|
|
1656
|
+
message = [' Changed Codex approval mode from "auto" to "writes".',
|
|
1657
|
+
' -> "auto" let Codex decide, and it decided to ask on every call.'];
|
|
1658
|
+
} else {
|
|
1659
|
+
// A bare key must precede any sub-table, so it goes at the very top of the body.
|
|
1660
|
+
newBody = '\ndefault_tools_approval_mode = "writes"' + body;
|
|
1661
|
+
message = [' Set default_tools_approval_mode = "writes" in ~/.codex/config.toml',
|
|
1662
|
+
" -> niro is read-only, so its tools run without per-call approval prompts."];
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
if (newBody === body) return; // nothing to do
|
|
1469
1666
|
|
|
1470
1667
|
try {
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
console.log("
|
|
1668
|
+
writeCodexConfigVerified(cfgPath, toml.slice(0, bodyStart) + newBody + toml.slice(bodyEnd));
|
|
1669
|
+
message.forEach((line) => console.log(line));
|
|
1670
|
+
console.log("");
|
|
1474
1671
|
} catch (err) {
|
|
1475
|
-
console.warn(` Could not update
|
|
1672
|
+
console.warn(` Could not update the Codex approval mode: ${err.message}\n`);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Locate [mcp_servers.niro]'s body in the CURRENT contents of config.toml.
|
|
1678
|
+
*
|
|
1679
|
+
* Returns the whole file plus the byte range of the table body (everything after the header, up to
|
|
1680
|
+
* the next table header at any level). Read fresh on every call: any offset computed before another
|
|
1681
|
+
* function rewrote the file is a splice into the wrong place waiting to happen.
|
|
1682
|
+
*/
|
|
1683
|
+
function locateCodexNiroTable(cfgPath) {
|
|
1684
|
+
const header = "[mcp_servers.niro]";
|
|
1685
|
+
let toml;
|
|
1686
|
+
try {
|
|
1687
|
+
toml = fs.readFileSync(cfgPath, "utf-8");
|
|
1688
|
+
} catch {
|
|
1689
|
+
return null;
|
|
1476
1690
|
}
|
|
1691
|
+
const idx = toml.indexOf(header);
|
|
1692
|
+
if (idx === -1) return null;
|
|
1693
|
+
|
|
1694
|
+
const bodyStart = idx + header.length;
|
|
1695
|
+
const after = toml.slice(bodyStart);
|
|
1696
|
+
const nextTableRel = after.search(/\n[ \t]*\[/);
|
|
1697
|
+
const bodyEnd = nextTableRel === -1 ? toml.length : bodyStart + nextTableRel;
|
|
1698
|
+
return { toml, bodyStart, bodyEnd, body: toml.slice(bodyStart, bodyEnd) };
|
|
1477
1699
|
}
|
|
1478
1700
|
|
|
1479
1701
|
/**
|
|
@@ -2125,6 +2347,9 @@ module.exports = {
|
|
|
2125
2347
|
isNiroUserPromptHook,
|
|
2126
2348
|
isNiroPreToolUseHook,
|
|
2127
2349
|
isNiroExploreHook,
|
|
2350
|
+
isNiroStopHook,
|
|
2351
|
+
clearCodexPerToolApprovals,
|
|
2352
|
+
hardenCodexServerConfig,
|
|
2128
2353
|
userHome,
|
|
2129
2354
|
// Exposed for tests: the hook bodies are shell, so the only way to prove they still fire (and still
|
|
2130
2355
|
// carry their identity token) is to execute the real string.
|
package/src/mcp/teardown.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* `niro mcp uninstall` removes everything `niro mcp install` wrote, per editor:
|
|
7
7
|
* - the MCP server registration (claude/codex CLI, or the editor's JSON config)
|
|
8
8
|
* - the niro hooks in ~/.claude/settings.json (PostCompact + UserPromptSubmit + PreToolUse: both the
|
|
9
|
-
* Edit|Write temp-project reminder and the Grep|Glob|Read explore-first reminder
|
|
9
|
+
* Edit|Write temp-project reminder and the Grep|Glob|Read explore-first reminder + Stop: the
|
|
10
|
+
* end-of-task receipt line), and any receipts still pending on disk
|
|
10
11
|
* - the niro section in markdown config (CLAUDE.md / AGENTS.md / Windsurf rules)
|
|
11
12
|
* - the generated skills (~/.claude/skills, the shared ~/.agents/skills)
|
|
12
13
|
* - Cursor's "niro:*" allowlist entry and project rule
|
|
@@ -36,6 +37,7 @@ const {
|
|
|
36
37
|
CLIENTS,
|
|
37
38
|
NIRO_MD_MARKER,
|
|
38
39
|
NIRO_MD_SECTIONS,
|
|
40
|
+
isNiroStopHook,
|
|
39
41
|
isNiroPostCompactHook,
|
|
40
42
|
isNiroUserPromptHook,
|
|
41
43
|
isNiroPreToolUseHook,
|
|
@@ -154,6 +156,12 @@ function filterNiroHooks(settings) {
|
|
|
154
156
|
removed += before - hooks.PreToolUse.length;
|
|
155
157
|
if (hooks.PreToolUse.length === 0) delete hooks.PreToolUse;
|
|
156
158
|
}
|
|
159
|
+
if (Array.isArray(hooks.Stop)) {
|
|
160
|
+
const before = hooks.Stop.length;
|
|
161
|
+
hooks.Stop = hooks.Stop.filter((h) => !isNiroStopHook(h));
|
|
162
|
+
removed += before - hooks.Stop.length;
|
|
163
|
+
if (hooks.Stop.length === 0) delete hooks.Stop;
|
|
164
|
+
}
|
|
157
165
|
if (Object.keys(hooks).length === 0) delete settings.hooks;
|
|
158
166
|
|
|
159
167
|
return { settings, removed };
|
|
@@ -375,6 +383,14 @@ function uninstallClaudeCode() {
|
|
|
375
383
|
const home = userHome();
|
|
376
384
|
runMcpRemove("claude", ["mcp", "remove", "niro", "--scope", "user"]);
|
|
377
385
|
stripNiroHooksFromSettings(path.join(home, ".claude", "settings.json"));
|
|
386
|
+
// Claude Code is the only client with the Stop hook, so its pending receipts have nothing left to
|
|
387
|
+
// read them. Leaving them behind would mean a reinstall months later opens with a line describing
|
|
388
|
+
// a task the developer has long forgotten.
|
|
389
|
+
try {
|
|
390
|
+
require("../lib/receiptStore").purge();
|
|
391
|
+
} catch {
|
|
392
|
+
/* best effort — never block an uninstall on cleaning up a cache */
|
|
393
|
+
}
|
|
378
394
|
stripMarkerFromFile(path.join(home, ".claude", "CLAUDE.md"), "Claude Code memory");
|
|
379
395
|
deleteFileIfPresent(path.join(home, ".claude", "skills", "niro-skill", "SKILL.md"), "niro skill");
|
|
380
396
|
deleteFileIfPresent(path.join(home, ".claude", "skills", "niro-cli", "SKILL.md"), "niro-cli skill");
|