@niroai/niro 0.4.0 → 0.6.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/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 = "auto"` — niro tools are all read-only, so no per-tool
1431
- * approval prompts (codex mcp add has no flag for this; we insert it directly after the
1432
- * table header, which is valid TOML — bare keys precede any sub-table). Idempotent and a
1433
- * safe no-op if the file/table is missing. Also prints the optional `required = true`
1434
- * recommendation (left off by default: it would make niro a hard Codex startup dependency).
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
- function hardenCodexServerConfig() {
1437
- const cfgPath = path.join(process.env.HOME || "", ".codex", "config.toml");
1438
- const header = "[mcp_servers.niro]";
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
- console.warn(" Could not read ~/.codex/config.toml to set the approval mode — skipping.\n");
1445
- return;
1564
+ return 0;
1446
1565
  }
1447
1566
 
1448
- const idx = toml.indexOf(header);
1449
- if (idx === -1) {
1450
- console.warn(" [mcp_servers.niro] not found in config.toml — skipping approval-mode hardening.\n");
1451
- return;
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
- // Limit the "already set?" check to the niro table body (up to the next table header).
1455
- const after = toml.slice(idx + header.length);
1456
- const nextTableRel = after.search(/\n\s*\[/);
1457
- const tableBody = nextTableRel === -1 ? after : after.slice(0, nextTableRel);
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
- if (/^\s*default_tools_approval_mode\s*=/m.test(tableBody)) {
1463
- console.log(" Codex approval mode already set — skipping.\n");
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
- const insertAt = idx + header.length;
1468
- const newToml = toml.slice(0, insertAt) + '\ndefault_tools_approval_mode = "auto"' + toml.slice(insertAt);
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
- fs.writeFileSync(cfgPath, newToml);
1472
- console.log(' Set default_tools_approval_mode = "auto" in ~/.codex/config.toml');
1473
- console.log(" → niro is read-only, so its tools run without per-call approval prompts.\n");
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 ~/.codex/config.toml: ${err.message}\n`);
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.
@@ -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");