@niroai/niro 0.16.0 → 0.18.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.
Files changed (177) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +123 -302
  3. package/bin/niro.js +12 -1
  4. package/docs/agent-antigravity.md +37 -0
  5. package/docs/agent-claude-code.md +48 -0
  6. package/docs/agent-claude-desktop.md +47 -0
  7. package/docs/agent-cline.md +42 -0
  8. package/docs/agent-codex.md +45 -0
  9. package/docs/agent-copilot-cli.md +42 -0
  10. package/docs/agent-cursor.md +44 -0
  11. package/docs/agent-factory.md +42 -0
  12. package/docs/agent-gemini-cli.md +42 -0
  13. package/docs/agent-goose.md +42 -0
  14. package/docs/agent-kiro.md +37 -0
  15. package/docs/agent-opencode.md +37 -0
  16. package/docs/agent-vscode.md +45 -0
  17. package/docs/agent-windsurf.md +38 -0
  18. package/docs/answer-shapes.md +69 -0
  19. package/docs/builds-and-updates.md +68 -0
  20. package/docs/cloud-README.md +372 -0
  21. package/docs/configuring-a-folder.md +51 -0
  22. package/docs/connect-ci.md +24 -0
  23. package/docs/connect-overview.md +20 -0
  24. package/docs/connect-review-bots.md +17 -0
  25. package/docs/connect-team-server.md +12 -0
  26. package/docs/connect-terminal.md +49 -0
  27. package/docs/connect-web-agents.md +9 -0
  28. package/docs/connect-your-ai-assistant.md +59 -175
  29. package/docs/coverage-audit-and-consent.md +51 -0
  30. package/docs/evaluate-what-niro-did.md +271 -0
  31. package/docs/faq-assistants-and-mcp.md +122 -0
  32. package/docs/faq-feedback-and-receipts.md +33 -0
  33. package/docs/faq-indexing.md +127 -0
  34. package/docs/faq-install-and-setup.md +103 -0
  35. package/docs/faq-licence-and-plans.md +86 -0
  36. package/docs/faq-login-and-account.md +69 -0
  37. package/docs/faq-offline.md +34 -0
  38. package/docs/faq-performance.md +33 -0
  39. package/docs/faq-privacy-and-security.md +113 -0
  40. package/docs/faq-uninstall.md +47 -0
  41. package/docs/faq-updates.md +59 -0
  42. package/docs/faq-worktrees-and-branches.md +59 -0
  43. package/docs/faq-wrong-or-missing-answers.md +275 -0
  44. package/docs/hosted-only.md +33 -0
  45. package/docs/how-the-graph-works.md +47 -0
  46. package/docs/index.md +157 -0
  47. package/docs/licence-and-plans.md +72 -0
  48. package/docs/lifecycle-debug.md +48 -0
  49. package/docs/lifecycle-onboarding.md +32 -0
  50. package/docs/lifecycle-overview.md +77 -0
  51. package/docs/lifecycle-plan-and-design.md +44 -0
  52. package/docs/lifecycle-review.md +30 -0
  53. package/docs/lifecycle-security-and-privacy.md +35 -0
  54. package/docs/lifecycle-test.md +28 -0
  55. package/docs/lifecycle-write-code.md +35 -0
  56. package/docs/llms-full.txt +8015 -0
  57. package/docs/llms.txt +126 -0
  58. package/docs/playbook-add-a-feature.md +108 -0
  59. package/docs/playbook-change-safely.md +93 -0
  60. package/docs/playbook-data-and-events.md +107 -0
  61. package/docs/playbook-debug-after-a-change.md +96 -0
  62. package/docs/playbook-debug-config.md +95 -0
  63. package/docs/playbook-debug-exception.md +124 -0
  64. package/docs/playbook-debug-failing-service-call.md +119 -0
  65. package/docs/playbook-debug-slow-path.md +80 -0
  66. package/docs/playbook-debug-wrong-value.md +137 -0
  67. package/docs/playbook-onboard.md +95 -0
  68. package/docs/playbook-review-a-change.md +72 -0
  69. package/docs/playbook-test-a-change.md +73 -0
  70. package/docs/reference-cli.md +113 -0
  71. package/docs/reference-config-files.md +42 -0
  72. package/docs/reference-engine-commands.md +56 -0
  73. package/docs/reference-environment-variables.md +89 -0
  74. package/docs/reference-exit-codes.md +67 -0
  75. package/docs/reference-limits.md +75 -0
  76. package/docs/reference-mcp-tools.md +326 -0
  77. package/docs/reference-messages.md +180 -0
  78. package/docs/secrets-and-masking.md +48 -0
  79. package/docs/supported-c.md +130 -0
  80. package/docs/supported-cpp.md +157 -0
  81. package/docs/supported-csharp.md +211 -0
  82. package/docs/supported-go.md +258 -0
  83. package/docs/supported-java.md +226 -0
  84. package/docs/supported-javascript.md +223 -0
  85. package/docs/supported-kotlin.md +124 -0
  86. package/docs/supported-overview.md +89 -0
  87. package/docs/supported-php.md +157 -0
  88. package/docs/supported-python.md +224 -0
  89. package/docs/supported-ruby.md +162 -0
  90. package/docs/supported-rust.md +150 -0
  91. package/docs/supported-swift.md +133 -0
  92. package/docs/supported-typescript.md +220 -0
  93. package/docs/tokens-speed-and-accuracy.md +99 -0
  94. package/docs/troubleshooting.md +42 -0
  95. package/docs/uninstall.md +74 -0
  96. package/docs/what-leaves-your-machine.md +71 -0
  97. package/docs/where-things-are-stored.md +59 -0
  98. package/docs/worktrees-and-branches.md +55 -0
  99. package/package.json +5 -3
  100. package/src/commands/add-repo.js +5 -0
  101. package/src/commands/auditSend.js +67 -0
  102. package/src/commands/build.js +6 -0
  103. package/src/commands/docsSend.js +73 -0
  104. package/src/commands/engine.js +120 -0
  105. package/src/commands/engineRoutes.js +105 -0
  106. package/src/commands/guide.js +254 -0
  107. package/src/commands/init.js +16 -7
  108. package/src/commands/initLocal.js +96 -0
  109. package/src/commands/journalSend.js +47 -0
  110. package/src/commands/login.js +89 -12
  111. package/src/commands/mcp.js +22 -6
  112. package/src/commands/plan.js +99 -0
  113. package/src/commands/repoPush.js +33 -0
  114. package/src/commands/requestIntegration.js +38 -0
  115. package/src/commands/share.js +56 -0
  116. package/src/commands/start.js +451 -371
  117. package/src/commands/status.js +57 -0
  118. package/src/commands/takeover.js +6 -0
  119. package/src/commands/uninstall.js +180 -0
  120. package/src/commands/upgrade.js +38 -8
  121. package/src/commands/watch.js +420 -6
  122. package/src/index.js +174 -3
  123. package/src/lib/agentApi.js +32 -1
  124. package/src/lib/api.js +20 -6
  125. package/src/lib/auditUpload.js +293 -0
  126. package/src/lib/branchDrift.js +149 -0
  127. package/src/lib/choose.js +116 -0
  128. package/src/lib/cloudGuard.js +74 -0
  129. package/src/lib/credentials.js +2 -2
  130. package/src/lib/docsMarked.js +55 -0
  131. package/src/lib/docsState.js +213 -0
  132. package/src/lib/docsUpload.js +481 -0
  133. package/src/lib/engine.js +619 -0
  134. package/src/lib/folderSync.js +2 -1
  135. package/src/lib/graphCheck.js +40 -0
  136. package/src/lib/guide.js +293 -0
  137. package/src/lib/guideSearch.js +252 -0
  138. package/src/lib/integrationRequest.js +89 -0
  139. package/src/lib/journalUpload.js +147 -0
  140. package/src/lib/languages.js +129 -0
  141. package/src/lib/lease.js +375 -0
  142. package/src/lib/localBuild.js +285 -0
  143. package/src/lib/localEngine.js +210 -0
  144. package/src/lib/localOnboard.js +124 -0
  145. package/src/lib/localWorktrees.js +243 -0
  146. package/src/lib/machineCheck.js +58 -0
  147. package/src/lib/mcpClients.js +10 -1
  148. package/src/lib/migrationNotice.js +118 -0
  149. package/src/lib/modelFetch.js +153 -0
  150. package/src/lib/nestedOutput.js +23 -9
  151. package/src/lib/onboardingLog.js +34 -0
  152. package/src/lib/planUsage.js +165 -0
  153. package/src/lib/progressBar.js +46 -0
  154. package/src/lib/prompt.js +1 -1
  155. package/src/lib/redact.js +155 -0
  156. package/src/lib/release.js +33 -0
  157. package/src/lib/repoPush.js +495 -0
  158. package/src/lib/secret.js +1 -1
  159. package/src/lib/service.js +372 -63
  160. package/src/lib/shareCode.js +367 -0
  161. package/src/lib/shareExclusions.js +114 -0
  162. package/src/lib/shareFiles.js +129 -0
  163. package/src/lib/spinner.js +9 -5
  164. package/src/lib/watchDaemon.js +33 -10
  165. package/src/lib/watchState.js +12 -1
  166. package/src/lib/windowsPaths.js +67 -0
  167. package/src/mcp/lineCheck.js +382 -0
  168. package/src/mcp/lineName.js +92 -0
  169. package/src/mcp/localBackend.js +751 -0
  170. package/src/mcp/localMode.js +383 -0
  171. package/src/mcp/localSkill.js +189 -0
  172. package/src/mcp/server.js +610 -79
  173. package/src/mcp/setup.js +519 -131
  174. package/src/mcp/shellSearchHook.js +509 -0
  175. package/src/mcp/teardown.js +173 -91
  176. package/src/mcp/usingNiro.js +39 -0
  177. package/docs/working-on-branches.md +0 -162
package/src/mcp/setup.js CHANGED
@@ -21,7 +21,13 @@ const path = require("path");
21
21
  const readline = require("readline");
22
22
  const { execSync } = require("child_process");
23
23
  const config = require("../lib/config");
24
+ const localMode = require("./localMode");
25
+ const shellSearchHook = require("./shellSearchHook");
24
26
  const { formatPrompt } = require("../lib/prompt");
27
+ const { choose } = require("../lib/choose");
28
+ const color = require("../lib/color");
29
+ /** Inside `niro start` these lines sit on its rail ("│ "), so they take the rail's indent. */
30
+ const IND = process.env.NIRO_OUTPUT_PREFIX ? " " : " ";
25
31
 
26
32
  /** Cross-platform user home (~). */
27
33
  function userHome() {
@@ -35,6 +41,7 @@ function userHome() {
35
41
  * (which removes the section) can never drift apart.
36
42
  */
37
43
  const NIRO_MD_MARKER = "<!-- niro-mcp -->";
44
+ const { USING_NIRO } = require("./usingNiro");
38
45
 
39
46
  /**
40
47
  * The exact niro sections appended to each markdown config file. These are the single source
@@ -73,10 +80,83 @@ const NIRO_WINDSURF_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP cod
73
80
  * copilot-instructions.md, Factory's AGENTS.md, OpenCode's AGENTS.md) — one wording, reused
74
81
  * across those destination files (teardown matches it per-file, so reuse is safe).
75
82
  */
76
- const NIRO_GENERIC_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase. Before adding or writing any new code, use find_reusable_code to check for reusable code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n`;
83
+ /**
84
+ * One line telling an agent with a terminal that Niro's own pages are readable offline (`niro guide`, in both
85
+ * releases). Given to the agents that get only a rule file and can run commands: Cursor, Cline, Kiro, and the
86
+ * generic section (Gemini CLI, Copilot CLI, Factory, OpenCode). Without backticks, so the Cursor rule can carry it.
87
+ */
88
+ const NIRO_GUIDE_SENTENCE = "For a Niro error message or a question about Niro itself, run niro guide --search <the message> in the terminal and answer from the page it names.";
89
+ const NIRO_GENERIC_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase. Before adding or writing any new code, use find_reusable_code to check for reusable code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_EXPLORE_FIRST_SENTENCE}\n${NIRO_IMPACT_BOTH_WAYS_SENTENCE}\n${NIRO_MULTI_REPO_SENTENCE}\n${NIRO_GUIDE_SENTENCE}\n`;
77
90
 
78
91
  /** All niro markdown sections — the uninstaller tries each for an exact removal. */
79
- const NIRO_MD_SECTIONS = [NIRO_CLAUDE_MD_SECTION, NIRO_CODEX_MD_SECTION, NIRO_WINDSURF_MD_SECTION, NIRO_GENERIC_MD_SECTION];
92
+ /**
93
+ * The instruction text for a release without the cloud: drops the rules that name what only the
94
+ * cloud has, the mandatory find_reusable_code call (an engine offers it only after a --llm build)
95
+ * and the temporary-project rule (`niro new-temp-project` is a cloud command). Every tool it names
96
+ * must be one the engine serves (test/releaseToolNames.test.js): the cloud's resolve_fqn is the
97
+ * engine's resolve_name, and the Cursor rule's cloud-only routes (explain_code,
98
+ * trace_execution_path, find_known_issues, get_state_at_entry, and the comments about hidden
99
+ * tools) are left out.
100
+ */
101
+ function localTextBeforeUsingNiro(text) {
102
+ return text
103
+ .split("resolve_fqn").join("resolve_name")
104
+ .replace(/\n<!-- [^\n]*\(hidden MCP tool\)[^\n]*-->/g, "")
105
+ .replace(/explain_code \(AI docs\) \| /g, "")
106
+ .replace(/ \| trace_execution_path \(A to B\)/g, "")
107
+ .replace(/\n- debug: find_known_issues \| get_state_at_entry/g, "")
108
+ .split(`\n${NIRO_MULTI_REPO_SENTENCE}`).join("")
109
+ .replace(/\n[^\n]*before writing\/adding any code: find_reusable_code[^\n]*/g, "")
110
+ .replace(/\n[ \t]*Before (?:adding\/writing|adding or writing|writing or adding|writing)(?: any)? new code,[^.]*find_reusable_code[^.]*\.[ \t]*(?=\n)/g, "")
111
+ .replace(/[ \t]*Before (?:adding\/writing|adding or writing|writing or adding|writing)(?: any)? new code,[^.]*find_reusable_code[^.]*\.[ \t]*/g, "")
112
+ .replace(/[ \t]*If find_reusable_code[^.]*\./g, "")
113
+ .replace(/(\S) +\n/g, "$1\n");
114
+ }
115
+
116
+ /**
117
+ * A release text with "Using Niro" in it (src/mcp/usingNiro.js): in place of the one guide sentence where a text has
118
+ * it (the generic section, the Cursor, Cline and Kiro rules), else at the end of a marked section (Claude Code's
119
+ * CLAUDE.md through localMode.js, Codex's AGENTS.md, Windsurf's global rules). A plain sentence passes unchanged.
120
+ */
121
+ function withUsingNiro(text) {
122
+ if (text.includes(NIRO_GUIDE_SENTENCE)) return text.split(NIRO_GUIDE_SENTENCE).join(USING_NIRO);
123
+ if (text.startsWith(`\n${NIRO_MD_MARKER}\n`)) return `${text}${USING_NIRO}\n`;
124
+ return text;
125
+ }
126
+
127
+ /** The text a release without the cloud installs: the engine's tool names, no cloud-only rules, and Using Niro. */
128
+ function localText(text) {
129
+ return withUsingNiro(localTextBeforeUsingNiro(text));
130
+ }
131
+
132
+ /** Pick the wording for this release. */
133
+ function forRelease(text) {
134
+ return require("../lib/release").cloud ? text : localText(text);
135
+ }
136
+
137
+ const CLOUD_MD_SECTIONS = [NIRO_CLAUDE_MD_SECTION, NIRO_CODEX_MD_SECTION, NIRO_WINDSURF_MD_SECTION, NIRO_GENERIC_MD_SECTION];
138
+ // Every wording, so teardown and a refresh remove a section whichever release wrote it: the cloud's, this release's,
139
+ // and this release's before Using Niro was added (9255e24), which `niro upgrade` must replace exactly.
140
+ const NIRO_MD_SECTIONS = [
141
+ ...CLOUD_MD_SECTIONS,
142
+ ...CLOUD_MD_SECTIONS.map(localText),
143
+ ...CLOUD_MD_SECTIONS.map(localTextBeforeUsingNiro),
144
+ ];
145
+ /**
146
+ * Every section the uninstaller removes exactly: the cloud ones above (both wordings) and local mode's
147
+ * (src/mcp/localMode.js). Kept apart because the cloud sections all carry the cloud rules word for word,
148
+ * which the local one, naming the engine's tools, deliberately does not.
149
+ */
150
+ /**
151
+ * The sections as the installer wrote them before NIRO_GUIDE_SENTENCE was added (3a585c8, the release before
152
+ * `niro guide`), so a refresh and the uninstaller remove those exactly too, instead of falling back to a
153
+ * bounded strip. Derived by taking the sentence out, and checked against the 3a585c8 texts in
154
+ * test/fixtures/md-sections-3a585c8.json.
155
+ */
156
+ const BEFORE_GUIDE_MD_SECTIONS = NIRO_MD_SECTIONS
157
+ .map((section) => section.split(`${NIRO_GUIDE_SENTENCE}\n`).join(""))
158
+ .filter((section) => !NIRO_MD_SECTIONS.includes(section));
159
+ const NIRO_ALL_MD_SECTIONS = [...NIRO_MD_SECTIONS, ...BEFORE_GUIDE_MD_SECTIONS, ...localMode.LOCAL_CLAUDE_MD_SECTIONS];
80
160
 
81
161
  /**
82
162
  * Upsert a niro markdown section into an agent-rules file's existing content. Returns {content, action}:
@@ -84,20 +164,20 @@ const NIRO_MD_SECTIONS = [NIRO_CLAUDE_MD_SECTION, NIRO_CODEX_MD_SECTION, NIRO_WI
84
164
  * - no niro marker at all -> {existing + section, "added"}
85
165
  * - marker present but content DIFFERS -> strip the old bounded niro block, append fresh, "refreshed"
86
166
  * The "refreshed" path fixes the review bug where an existing user never received UPDATED instructions (the
87
- * new temp-project sentence) because the installer skipped whenever the marker was present. The strip is
88
- * bounded: it removes only from the niro block's start to the next top-level `## ` heading (or EOF), so a
89
- * user's own sections below the niro block are preserved.
167
+ * new temp-project sentence) because the installer skipped whenever the marker was present. The old block is
168
+ * removed as teardown removes it (removeMarkerSection): a known section text exactly, otherwise only the lines a
169
+ * known section holds, up to the first blank line, so every line a user wrote below it survives.
90
170
  */
91
171
  function upsertNiroMarkerSection(existing, section) {
92
172
  if (existing.includes(section)) return { content: existing, action: "unchanged" };
93
173
  if (!existing.includes(NIRO_MD_MARKER)) return { content: existing + section, action: "added" };
94
- const idx = existing.indexOf(NIRO_MD_MARKER);
95
- let start = idx;
96
- while (start > 0 && existing[start - 1] === "\n") start--;
97
- const ourHeading = existing.indexOf("\n## ", idx); // niro's own "## Niro MCP" heading
98
- const nextHeading = ourHeading < 0 ? -1 : existing.indexOf("\n## ", ourHeading + 4);
99
- const end = nextHeading < 0 ? existing.length : nextHeading;
100
- return { content: existing.slice(0, start) + existing.slice(end) + section, action: "refreshed" };
174
+ // The old section comes out the way the uninstaller takes it out: every section text this installer has
175
+ // written, exactly, and only for a text it does not know, the lines of the marker's block (up to the first blank
176
+ // line) that a known section holds. Until 2026-11-10 this cut everything up to the next "## " heading, so a
177
+ // user's note written under the section (no heading, a "#" or "###" heading) was deleted on every refresh
178
+ // (check of f1abc9f).
179
+ const { content } = require("./teardown").removeMarkerSection(existing, NIRO_MD_MARKER, NIRO_ALL_MD_SECTIONS);
180
+ return { content: content + section, action: "refreshed" };
101
181
  }
102
182
 
103
183
  /**
@@ -121,7 +201,10 @@ function isNiroUserPromptHook(h) {
121
201
  const cmds = [];
122
202
  if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
123
203
  if (h && typeof h.command === "string") cmds.push(h.command);
124
- return cmds.some((c) => c.includes(".niro-project") || c.includes("find_reusable_code") || c.includes("`niro` skill"));
204
+ // The local reminder (localMode.js) has neither `.niro-project` nor, with LLM documentation off, find_reusable_code:
205
+ // its generated opening is what names it.
206
+ return cmds.some((c) => c.includes(".niro-project") || c.includes("find_reusable_code") || c.includes("`niro` skill")
207
+ || c.includes(NIRO_USER_PROMPT_GENERATED_MARKERS[0]));
125
208
  }
126
209
 
127
210
  /**
@@ -149,6 +232,18 @@ function isNiroStopHook(h) {
149
232
  return cmds.some((c) => c.includes("mcp receipt --hook"));
150
233
  }
151
234
 
235
+ /**
236
+ * True if `h` is niro's line-check Stop hook. Keyed on the exact subcommand it runs, so a developer's
237
+ * own Stop hook is never matched. Deliberately not isNiroStopHook, which finds the retired hook that
238
+ * printed the line itself so an install can delete it; this one is meant to stay.
239
+ */
240
+ function isNiroLineCheckHook(h) {
241
+ const cmds = [];
242
+ if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
243
+ if (h && typeof h.command === "string") cmds.push(h.command);
244
+ return cmds.some((c) => c.includes(NIRO_LINE_CHECK_SUBCOMMAND));
245
+ }
246
+
152
247
  /**
153
248
  * True if `h` is niro's PreToolUse explore-first reminder entry (matcher Grep|Glob|Read). Keyed on the
154
249
  * unique `niro-explore-` marker-file prefix so a foreign PreToolUse hook on the same matcher is never
@@ -174,6 +269,21 @@ function isNiroImpactHook(h) {
174
269
  return cmds.some((c) => c.includes("niro-impact-"));
175
270
  }
176
271
 
272
+ /**
273
+ * The environment every assistant starts `niro mcp serve` with: the key when there is no `niro login` session, and the
274
+ * bridge's server address. With the cloud off (src/lib/release.js) it also carries NIRO_MCP_MODE=local, the setting
275
+ * Claude Code is registered with (setupClaudeCode), so every assistant's bridge answers from the engine on this
276
+ * machine. Without it, an assistant started with no login stopped at the bridge's credential check before the engine
277
+ * was started (13 of 14 clients, the checker's note 1 on fix/cli-small, 2026-11-06).
278
+ */
279
+ function bridgeEnv(apiKey, mcpServerUrl) {
280
+ return {
281
+ ...(apiKey ? { NIRO_API_KEY: apiKey } : {}),
282
+ NIRO_MCP_SERVER_URL: mcpServerUrl,
283
+ ...(require("../lib/release").cloud ? {} : { NIRO_MCP_MODE: "local" }),
284
+ };
285
+ }
286
+
177
287
  /**
178
288
  * Writes a niro-managed skill/rule file, refreshing it when the generated content has
179
289
  * changed. These files are GENERATED (not hand-edited), so refresh-on-change is correct:
@@ -183,7 +293,11 @@ function isNiroImpactHook(h) {
183
293
  * `label` is a short human name used in the log line (e.g. "niro skill").
184
294
  * Returns one of: "added" | "unchanged" | "updated".
185
295
  */
296
+ /** Skills written or refreshed during this run, so the summary can say where they went. */
297
+ const installedSkills = [];
298
+
186
299
  function writeManagedSkill(absPath, content, label) {
300
+ installedSkills.push({ label, dir: path.dirname(absPath) });
187
301
  if (!fs.existsSync(absPath)) {
188
302
  fs.mkdirSync(path.dirname(absPath), { recursive: true });
189
303
  fs.writeFileSync(absPath, content);
@@ -249,7 +363,7 @@ const CLIENTS = {
249
363
  type: "stdio",
250
364
  command: "niro",
251
365
  args: ["mcp", "serve"],
252
- env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
366
+ env: bridgeEnv(apiKey, mcpServerUrl),
253
367
  }),
254
368
  },
255
369
  "gemini-cli": {
@@ -277,9 +391,7 @@ const CLIENTS = {
277
391
  type: "local",
278
392
  command: ["niro", "mcp", "serve"],
279
393
  enabled: true,
280
- environment: apiKey
281
- ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl }
282
- : { NIRO_MCP_SERVER_URL: mcpServerUrl },
394
+ environment: bridgeEnv(apiKey, mcpServerUrl),
283
395
  }),
284
396
  },
285
397
  cline: {
@@ -300,7 +412,7 @@ const CLIENTS = {
300
412
  buildEntry: (apiKey, mcpServerUrl) => ({
301
413
  command: "niro",
302
414
  args: ["mcp", "serve"],
303
- env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
415
+ env: bridgeEnv(apiKey, mcpServerUrl),
304
416
  disabled: false,
305
417
  autoApprove: [],
306
418
  }),
@@ -312,7 +424,7 @@ const CLIENTS = {
312
424
  buildEntry: (apiKey, mcpServerUrl) => ({
313
425
  command: "niro",
314
426
  args: ["mcp", "serve"],
315
- env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
427
+ env: bridgeEnv(apiKey, mcpServerUrl),
316
428
  disabled: false,
317
429
  // Kiro's autoApprove supports a "*" wildcard for "all tools on this server" — safe here
318
430
  // because every niro tool is read-only.
@@ -334,7 +446,7 @@ const CLIENTS = {
334
446
  buildEntry: (apiKey, mcpServerUrl) => ({
335
447
  command: "niro",
336
448
  args: ["mcp", "serve"],
337
- env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
449
+ env: bridgeEnv(apiKey, mcpServerUrl),
338
450
  }),
339
451
  },
340
452
  };
@@ -556,13 +668,6 @@ function hasShellMetacharacters(value) {
556
668
  return /[\s;&|`$()<>"'\\]/.test(value || "");
557
669
  }
558
670
 
559
- function printHeader() {
560
- console.log("");
561
- console.log(" Niro MCP Server Setup");
562
- console.log(" ---------------------");
563
- console.log("");
564
- }
565
-
566
671
  /** How many clients to name before summarising the rest. */
567
672
  const CLIENTS_SHOWN = 5;
568
673
 
@@ -588,6 +693,22 @@ function runClientCommand(command) {
588
693
  * a question nearly everyone answers with the default. The full list is one flag away, and
589
694
  * anyone hunting for a client they cannot see is exactly the person who will use it.
590
695
  */
696
+ /** The picker's last row: an MCP client not in CLIENTS. */
697
+ const OTHER = "other";
698
+
699
+ /** The stdio entry every JSON client gets (setupJsonConfig's default), to paste by hand. */
700
+ function printOtherConfig(apiKey, mcpServerUrl) {
701
+ const env = bridgeEnv(apiKey, mcpServerUrl);
702
+ const entry = { mcpServers: { niro: { command: "niro", args: ["mcp", "serve"], env } } };
703
+ console.log(`${IND}Add this to your assistant's MCP settings, then restart it:`);
704
+ JSON.stringify(entry, null, 2).split("\n").forEach((l) => console.log(color.dim(`${IND} ${l}`)));
705
+ }
706
+
707
+ /** "claude-code, cursor" or "claude-code cursor" → unique ids in the order typed. */
708
+ function parseClients(input) {
709
+ return [...new Set(String(input || "").split(/[\s,]+/).filter(Boolean))];
710
+ }
711
+
591
712
  function printClients({ all = false } = {}) {
592
713
  const entries = Object.entries(CLIENTS);
593
714
  const shown = all ? entries : entries.slice(0, CLIENTS_SHOWN);
@@ -615,10 +736,12 @@ function registeredServerUrl(getOutput) {
615
736
  * "keep" registered and already pointing at the wanted URL
616
737
  * "replace" registered, but at another URL or in a shape this cannot read: remove, then add
617
738
  */
618
- function planClaudeRegistration({ existingOutput, wantedUrl }) {
739
+ function planClaudeRegistration({ existingOutput, wantedUrl, mode = "cloud" }) {
619
740
  if (existingOutput == null) return "add";
741
+ const local = /NIRO_MCP_MODE=local\b/.test(String(existingOutput));
742
+ if (mode === "local") return local ? "keep" : "replace";
620
743
  const url = registeredServerUrl(existingOutput);
621
- return url && url === wantedUrl ? "keep" : "replace";
744
+ return !local && url && url === wantedUrl ? "keep" : "replace";
622
745
  }
623
746
 
624
747
  /**
@@ -635,8 +758,12 @@ function planClaudeRegistration({ existingOutput, wantedUrl }) {
635
758
  * `deps` is for tests only: `get` returns the `claude mcp get niro` output or null, `run` runs a
636
759
  * client command; both default to the real thing.
637
760
  */
638
- async function setupClaudeCode(apiKey, mcpServerUrl, deps = {}) {
639
- console.log("\n Configuring Claude Code...\n");
761
+ async function setupClaudeCode(apiKey, mcpServerUrl, deps = {}, mode = "cloud") {
762
+ // A release without the cloud (src/lib/release.js) sets Claude Code up for the engine on this machine: the bridge
763
+ // in local mode and the hooks that read niro-local's own list of indexed folders, not the cloud hooks that look
764
+ // for a `.niro-project` file and so never fire in a Niro Local folder (Niraj, 2026-11-06, Q5).
765
+ if (!require("../lib/release").cloud) mode = "local";
766
+ console.log(`\n Configuring Claude Code${mode === "local" ? " for niro-local, the engine on this machine" : ""}...\n`);
640
767
  const run = deps.run || runClientCommand;
641
768
  const get = deps.get || (() => {
642
769
  try {
@@ -651,16 +778,25 @@ async function setupClaudeCode(apiKey, mcpServerUrl, deps = {}) {
651
778
  "--scope", "user",
652
779
  "niro",
653
780
  ];
654
- // Only embed NIRO_API_KEY when there is no cli-token (legacy/key flow). With a `niro login`
655
- // session the server reads the cli-token from ~/.niro/credentials.json at runtime.
656
- if (apiKey) cmd.push("-e", `NIRO_API_KEY=${apiKey}`);
657
- // The bridge (src/mcp/server.js) reads NIRO_MCP_SERVER_URL (ai-assistant), not NIRO_API_URL.
658
- cmd.push("-e", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
781
+ if (mode === "local") {
782
+ // The same bridge, told to answer everything from the engine on this machine: initialize and the
783
+ // tool list as well as tool calls (src/mcp/server.js, localSend). No server URL and no key: nothing
784
+ // is asked of the cloud to answer, and the licence comes from the `niro login` session.
785
+ cmd.push("-e", "NIRO_MCP_MODE=local");
786
+ } else {
787
+ // Only embed NIRO_API_KEY when there is no cli-token (legacy/key flow). With a `niro login`
788
+ // session the server reads the cli-token from ~/.niro/credentials.json at runtime.
789
+ if (apiKey) cmd.push("-e", `NIRO_API_KEY=${apiKey}`);
790
+ // The bridge (src/mcp/server.js) reads NIRO_MCP_SERVER_URL (ai-assistant), not NIRO_API_URL.
791
+ cmd.push("-e", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
792
+ }
659
793
  cmd.push("--", "niro", "mcp", "serve");
660
794
 
661
- const plan = planClaudeRegistration({ existingOutput: get(), wantedUrl: mcpServerUrl });
795
+ const plan = planClaudeRegistration({ existingOutput: get(), wantedUrl: mcpServerUrl, mode });
662
796
  if (plan === "keep") {
663
- console.log(` MCP server already registered in Claude Code at ${mcpServerUrl}. Keeping it.\n`);
797
+ console.log(mode === "local"
798
+ ? " MCP server already registered in Claude Code for niro-local. Keeping it.\n"
799
+ : ` MCP server already registered in Claude Code at ${mcpServerUrl}. Keeping it.\n`);
664
800
  } else {
665
801
  if (plan === "replace") {
666
802
  try {
@@ -681,12 +817,37 @@ async function setupClaudeCode(apiKey, mcpServerUrl, deps = {}) {
681
817
  }
682
818
  }
683
819
 
820
+ if (mode === "local") {
821
+ addClaudeCodePostCompactHook(localMode.LOCAL_POST_COMPACT_COMMAND);
822
+ addClaudeCodeUserPromptHook(localMode.LOCAL_USER_PROMPT_COMMAND);
823
+ // Temporary projects are a cloud idea: niro-local follows the folder whatever branch it is on.
824
+ removeClaudeCodeTempProjectHook();
825
+ addClaudeCodeExploreHook(localMode.localHook(NIRO_EXPLORE_COMMAND));
826
+ shellSearchHook.addClaudeCodeShellSearchHook(localMode.localHook(shellSearchHook.NIRO_SHELL_SEARCH_COMMAND));
827
+ addClaudeCodeImpactHook(localMode.localHook(NIRO_IMPACT_COMMAND));
828
+ removeStaleNiroStopHook();
829
+ addClaudeCodeLineCheckHook();
830
+ addClaudeCodeMemory(localMode.LOCAL_CLAUDE_MD_SECTION);
831
+ // The two skills: local mode's own in a cloud release. With the cloud off, the release's own (src/mcp/localSkill.js):
832
+ // the tool skill naming the engine's tools, and the `niro-local` command skill, whose commands (`niro init`,
833
+ // `niro build`, `niro plan`) are the ones that release has (Niraj, 2026-11-06, Q5: our hooks, her skill text).
834
+ if (require("../lib/release").cloud) {
835
+ addClaudeCodeSkill(localMode.LOCAL_SKILL);
836
+ addClaudeCodeCliSkill(localMode.LOCAL_CLI_SKILL);
837
+ } else {
838
+ addClaudeCodeSkill();
839
+ addClaudeCodeCliSkill();
840
+ }
841
+ return true;
842
+ }
684
843
  addClaudeCodePostCompactHook();
685
844
  addClaudeCodeUserPromptHook();
686
845
  addClaudeCodePreToolUseHook();
687
846
  addClaudeCodeExploreHook();
847
+ shellSearchHook.addClaudeCodeShellSearchHook();
688
848
  addClaudeCodeImpactHook();
689
849
  removeStaleNiroStopHook();
850
+ addClaudeCodeLineCheckHook();
690
851
  addClaudeCodeMemory();
691
852
  addClaudeCodeSkill();
692
853
  addClaudeCodeCliSkill();
@@ -694,6 +855,31 @@ async function setupClaudeCode(apiKey, mcpServerUrl, deps = {}) {
694
855
  return true;
695
856
  }
696
857
 
858
+ /**
859
+ * Take niro's temporary-project hook out of ~/.claude/settings.json (local mode, where it does not
860
+ * apply). Only entries isNiroPreToolUseHook recognises are touched; a missing or unreadable file is
861
+ * left as it is.
862
+ */
863
+ function removeClaudeCodeTempProjectHook() {
864
+ const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
865
+ let settings;
866
+ try {
867
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
868
+ } catch {
869
+ return;
870
+ }
871
+ const hooks = settings && settings.hooks && settings.hooks.PreToolUse;
872
+ if (!Array.isArray(hooks) || !hooks.some(isNiroPreToolUseHook)) return;
873
+ settings.hooks.PreToolUse = hooks.filter((h) => !isNiroPreToolUseHook(h));
874
+ if (settings.hooks.PreToolUse.length === 0) delete settings.hooks.PreToolUse;
875
+ try {
876
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
877
+ console.log(" PreToolUse temporary project hook removed (it does not apply to niro-local).\n");
878
+ } catch (err) {
879
+ console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
880
+ }
881
+ }
882
+
697
883
  /**
698
884
  * Adds a PostCompact hook to ~/.claude/settings.json so Claude is reminded
699
885
  * about Niro tools after context compaction (the point where tool awareness
@@ -742,16 +928,15 @@ const NIRO_POST_COMPACT_COMMAND =
742
928
  + "Keep that list and verify those flows after the edit; the graph lags your edit, so do not re-query it to verify. "
743
929
  + "A log line or comment needs no map.'";
744
930
 
745
- function addClaudeCodePostCompactHook() {
931
+ function addClaudeCodePostCompactHook(hookCommand = NIRO_POST_COMPACT_COMMAND) {
746
932
  // Guard the invariant that keeps upgrade-in-place working: if the reminder is ever reworded so it
747
933
  // no longer starts with the generated prefix, every install would append a fresh entry instead of
748
934
  // replacing the old one. Fail loudly at the boundary rather than quietly stacking hooks.
749
- if (!NIRO_POST_COMPACT_COMMAND.startsWith(NIRO_POST_COMPACT_PREFIX)) {
750
- throw new Error("NIRO_POST_COMPACT_COMMAND must start with NIRO_POST_COMPACT_PREFIX, or upgrade-in-place breaks");
935
+ if (!hookCommand.startsWith(NIRO_POST_COMPACT_PREFIX)) {
936
+ throw new Error("the PostCompact command must start with NIRO_POST_COMPACT_PREFIX, or upgrade-in-place breaks");
751
937
  }
752
938
 
753
939
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
754
- const hookCommand = NIRO_POST_COMPACT_COMMAND;
755
940
 
756
941
  let settings = {};
757
942
  try {
@@ -801,9 +986,8 @@ function addClaudeCodePostCompactHook() {
801
986
  * Appends a Niro reminder section to ~/.claude/CLAUDE.md so Claude is aware
802
987
  * of Niro tools across all projects and sessions.
803
988
  */
804
- function addClaudeCodeMemory() {
989
+ function addClaudeCodeMemory(section = forRelease(NIRO_CLAUDE_MD_SECTION)) {
805
990
  const claudeMdPath = path.join(process.env.HOME || "", ".claude", "CLAUDE.md");
806
- const section = NIRO_CLAUDE_MD_SECTION;
807
991
 
808
992
  let existing = "";
809
993
  try {
@@ -900,16 +1084,15 @@ function isNiroGeneratedUserPromptHook(h) {
900
1084
  );
901
1085
  }
902
1086
 
903
- function addClaudeCodeUserPromptHook() {
1087
+ function addClaudeCodeUserPromptHook(hookCommand = NIRO_USER_PROMPT_COMMAND) {
904
1088
  // Guard the invariant that keeps upgrade-in-place working: if the reminder is reworded so it no longer
905
1089
  // carries a known generated marker, every install would append a fresh entry instead of replacing the
906
1090
  // old one. Fail loudly at the boundary rather than quietly stacking hooks in the user's settings.
907
- if (!NIRO_USER_PROMPT_GENERATED_MARKERS.some((m) => NIRO_USER_PROMPT_COMMAND.includes(m))) {
908
- throw new Error("NIRO_USER_PROMPT_COMMAND must contain a known generated marker, or upgrade-in-place breaks");
1091
+ if (!NIRO_USER_PROMPT_GENERATED_MARKERS.some((m) => hookCommand.includes(m))) {
1092
+ throw new Error("the UserPromptSubmit command must contain a known generated marker, or upgrade-in-place breaks");
909
1093
  }
910
1094
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
911
- const hookCommand = NIRO_USER_PROMPT_COMMAND;
912
-
1095
+
913
1096
  let settings = {};
914
1097
  try {
915
1098
  if (fs.existsSync(settingsPath)) {
@@ -991,6 +1174,51 @@ const NIRO_PRETOOL_COMMAND = [
991
1174
  `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."}}'`,
992
1175
  ].join('; ');
993
1176
 
1177
+ /** The subcommand the line-check hook runs, which is also how the installer recognises its own. */
1178
+ const NIRO_LINE_CHECK_SUBCOMMAND = "mcp line-check";
1179
+ /**
1180
+ * Guarded, because this one fires on every stop in every session, Niro or not. A person who removed the
1181
+ * package without `niro mcp uninstall` would otherwise see an error at the end of every turn.
1182
+ */
1183
+ const NIRO_LINE_CHECK_COMMAND = `command -v niro >/dev/null 2>&1 && niro ${NIRO_LINE_CHECK_SUBCOMMAND} || true`;
1184
+ /** Seconds. Claude Code's default for a command hook is 600, far too long to hold up a stop. */
1185
+ const NIRO_LINE_CHECK_TIMEOUT = 5;
1186
+
1187
+ /**
1188
+ * Adds the Stop hook that asks the assistant, once, for an end-of-task line it forgot
1189
+ * (src/mcp/lineCheck.js). Same file, same shape, and the same care as the other hooks: an unreadable
1190
+ * settings file is left alone, and anything not recognised by isNiroLineCheckHook is never touched.
1191
+ */
1192
+ function addClaudeCodeLineCheckHook() {
1193
+ const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
1194
+ let settings = {};
1195
+ try {
1196
+ if (fs.existsSync(settingsPath)) settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
1197
+ } catch (err) {
1198
+ console.warn(` Could not read ~/.claude/settings.json, so the end-of-task check was not added: ${err.message}`);
1199
+ return;
1200
+ }
1201
+ const entry = {
1202
+ matcher: "",
1203
+ hooks: [{ type: "command", command: NIRO_LINE_CHECK_COMMAND, timeout: NIRO_LINE_CHECK_TIMEOUT }],
1204
+ };
1205
+ if (!settings.hooks) settings.hooks = {};
1206
+ const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
1207
+ const current = stop.filter(isNiroLineCheckHook);
1208
+ if (current.length === 1 && JSON.stringify(current[0]) === JSON.stringify(entry)) {
1209
+ console.log(" End-of-task check already up to date. Skipping.\n");
1210
+ return;
1211
+ }
1212
+ settings.hooks.Stop = [...stop.filter((h) => !isNiroLineCheckHook(h)), entry];
1213
+ try {
1214
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
1215
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1216
+ console.log(" End-of-task check added in ~/.claude/settings.json: it asks for the Niro line when it was forgotten.\n");
1217
+ } catch (err) {
1218
+ console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
1219
+ }
1220
+ }
1221
+
994
1222
  /**
995
1223
  * Removes a Stop hook left behind by an earlier version of this installer.
996
1224
  *
@@ -1136,7 +1364,7 @@ const NIRO_EXPLORE_COMMAND = [
1136
1364
  * upgrades a stale niro entry in place, never touches a foreign PreToolUse hook on the same matcher
1137
1365
  * (matched by the niro-explore- marker via isNiroExploreHook).
1138
1366
  */
1139
- function addClaudeCodeExploreHook() {
1367
+ function addClaudeCodeExploreHook(hookCommand = NIRO_EXPLORE_COMMAND) {
1140
1368
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
1141
1369
  let settings = {};
1142
1370
  try {
@@ -1148,9 +1376,9 @@ function addClaudeCodeExploreHook() {
1148
1376
  if (!settings.hooks) settings.hooks = {};
1149
1377
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
1150
1378
 
1151
- const entry = { matcher: "Grep|Glob|Read", hooks: [{ type: "command", command: NIRO_EXPLORE_COMMAND }] };
1379
+ const entry = { matcher: "Grep|Glob|Read", hooks: [{ type: "command", command: hookCommand }] };
1152
1380
  const alreadyCurrent = settings.hooks.PreToolUse.some(
1153
- (h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === NIRO_EXPLORE_COMMAND),
1381
+ (h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === hookCommand),
1154
1382
  );
1155
1383
  if (alreadyCurrent) {
1156
1384
  console.log(" PreToolUse explore-first hook already up to date. Skipping.\n");
@@ -1293,7 +1521,7 @@ const NIRO_IMPACT_COMMAND = [
1293
1521
  * stale niro entry in place, never touches a foreign PreToolUse hook on the same matcher nor niro's own
1294
1522
  * temp-project hook (matched by the niro-impact- token via isNiroImpactHook).
1295
1523
  */
1296
- function addClaudeCodeImpactHook() {
1524
+ function addClaudeCodeImpactHook(hookCommand = NIRO_IMPACT_COMMAND) {
1297
1525
  const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
1298
1526
  let settings = {};
1299
1527
  try {
@@ -1305,9 +1533,9 @@ function addClaudeCodeImpactHook() {
1305
1533
  if (!settings.hooks) settings.hooks = {};
1306
1534
  if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
1307
1535
 
1308
- const entry = { matcher: "Edit|Write", hooks: [{ type: "command", command: NIRO_IMPACT_COMMAND }] };
1536
+ const entry = { matcher: "Edit|Write", hooks: [{ type: "command", command: hookCommand }] };
1309
1537
  const alreadyCurrent = settings.hooks.PreToolUse.some(
1310
- (h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === NIRO_IMPACT_COMMAND),
1538
+ (h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === hookCommand),
1311
1539
  );
1312
1540
  if (alreadyCurrent) {
1313
1541
  console.log(" PreToolUse impact-gate hook already up to date. Skipping.\n");
@@ -1570,6 +1798,8 @@ You manage a user's Niro projects by running the \`niro\` CLI via the shell (Bas
1570
1798
  its \`--json\` output. You are a PARAM-COLLECTOR, not a decision engine: gather the inputs you
1571
1799
  need in conversation, then run ONE non-interactive \`niro\` command, parse the JSON, and report
1572
1800
  back in plain English. Do not invent business logic — the CLI owns it.
1801
+ For a Niro error message or a how-to question, run \`niro guide --search <the message>\` first and
1802
+ answer from the page it names (\`niro guide <page>\` prints it; the pages ship with niro, offline).
1573
1803
 
1574
1804
  ## Golden rules (apply to EVERY task)
1575
1805
 
@@ -1737,10 +1967,10 @@ state, and the next sensible step. Never dump raw secrets or env values.
1737
1967
  * Writes the `niro` skill to ~/.claude/skills/niro-skill/SKILL.md.
1738
1968
  * This file is niro-generated; it is refreshed when the bundled content changes.
1739
1969
  */
1740
- function addClaudeCodeSkill() {
1970
+ function addClaudeCodeSkill(content = toolSkill()) {
1741
1971
  const skillPath = path.join(process.env.HOME || "", ".claude", "skills", "niro-skill", "SKILL.md");
1742
1972
  try {
1743
- const result = writeManagedSkill(skillPath, NIRO_SKILL, "niro skill");
1973
+ const result = writeManagedSkill(skillPath, content, "niro skill");
1744
1974
  if (result === "added" || result === "updated") {
1745
1975
  }
1746
1976
  console.log("");
@@ -1749,15 +1979,47 @@ function addClaudeCodeSkill() {
1749
1979
  }
1750
1980
  }
1751
1981
 
1982
+ /**
1983
+ * Which CLI skill this release installs: the cloud `niro-cli` playbook, or, with the cloud off
1984
+ * (src/lib/release.js), the `niro-local` one, whose commands are the ones this release has.
1985
+ */
1986
+ function cliSkill(cloudLabel) {
1987
+ if (require("../lib/release").cloud) return { dir: "niro-cli", body: NIRO_CLI_SKILL, label: cloudLabel };
1988
+ return { dir: "niro-local", body: require("./localSkill").NIRO_LOCAL_SKILL, label: cloudLabel.replace("niro-cli", "niro-local") };
1989
+ }
1990
+
1991
+ /** The tool-usage skill: the cloud's, or with the cloud off one naming only the local engine's tools. */
1992
+ function toolSkill() {
1993
+ return require("../lib/release").cloud ? NIRO_SKILL : require("./localSkill").NIRO_SKILL_LOCAL;
1994
+ }
1995
+
1996
+ /** Take out a `niro-cli` skill an earlier install wrote, so it does not point at hidden commands. */
1997
+ function dropCloudCliSkill(skillsDir) {
1998
+ const file = path.join(skillsDir, "niro-cli", "SKILL.md");
1999
+ try {
2000
+ if (!fs.existsSync(file)) return;
2001
+ fs.unlinkSync(file);
2002
+ try { fs.rmdirSync(path.dirname(file)); } catch { /* not empty: something else lives there */ }
2003
+ console.log(` niro-cli skill removed (this release uses niro-local): ${file}`);
2004
+ } catch (err) {
2005
+ console.warn(` Could not remove ${file}: ${err.message}`);
2006
+ }
2007
+ }
2008
+
1752
2009
  /**
1753
2010
  * Writes the `niro-cli` skill to ~/.claude/skills/niro-cli/SKILL.md.
1754
2011
  * This file is niro-generated; it is refreshed when the bundled content changes.
1755
2012
  * Mirrors addClaudeCodeSkill.
1756
2013
  */
1757
- function addClaudeCodeCliSkill() {
1758
- const skillPath = path.join(process.env.HOME || "", ".claude", "skills", "niro-cli", "SKILL.md");
2014
+ function addClaudeCodeCliSkill(content) {
2015
+ // No content: this release's own CLI skill (the cloud `niro-cli` one, or `niro-local` with the cloud off). Content:
2016
+ // local mode in a cloud release, which writes its own text to the `niro-cli` skill file.
2017
+ const { dir, body, label } =
2018
+ content === undefined ? cliSkill("niro-cli skill") : { dir: "niro-cli", body: content, label: "niro-cli skill" };
2019
+ const skillPath = path.join(process.env.HOME || "", ".claude", "skills", dir, "SKILL.md");
2020
+ if (dir !== "niro-cli") dropCloudCliSkill(path.join(process.env.HOME || "", ".claude", "skills"));
1759
2021
  try {
1760
- const result = writeManagedSkill(skillPath, NIRO_CLI_SKILL, "niro-cli skill");
2022
+ const result = writeManagedSkill(skillPath, body, label);
1761
2023
  if (result === "added" || result === "updated") {
1762
2024
  }
1763
2025
  console.log("");
@@ -1823,24 +2085,59 @@ Routing (most specific wins):
1823
2085
  - data & events: get_data_resource_usage | get_event_producers_and_handlers
1824
2086
  - search / status: code_grep | get_index_status
1825
2087
 
2088
+ ${NIRO_GUIDE_SENTENCE}
2089
+
1826
2090
  Onboarding / indexing a repo is done in the terminal with the niro CLI (niro init), not
1827
2091
  via MCP tools.
1828
2092
  `;
1829
2093
 
2094
+ /** How every version of NIRO_CURSOR_RULE has begun and ended (all six in git history, 2026-11-06). */
2095
+ const CURSOR_RULE_START = "---\ndescription: Niro code-intelligence";
2096
+ const CURSOR_RULE_END = "\nvia MCP tools.\n";
2097
+
2098
+ /**
2099
+ * A Cursor rule file with Niro's part replaced by `fresh`, and every line the user added before or after it kept.
2100
+ * Niro's part is the span from its front matter to its last line ("via MCP tools."), which every version written so
2101
+ * far shares. Returns {content, action}: "unchanged" when it already says `fresh`, "refreshed" when Niro's part was
2102
+ * replaced, and "foreign" for a file Niro did not write (no such span), which is left alone. A line typed INSIDE
2103
+ * Niro's part goes with it, as it does for the markdown sections (upsertNiroMarkerSection).
2104
+ */
2105
+ function refreshCursorRule(existing, fresh) {
2106
+ const start = existing.indexOf(CURSOR_RULE_START);
2107
+ const endAt = start === -1 ? -1 : existing.indexOf(CURSOR_RULE_END, start);
2108
+ const ending = endAt === -1 && start !== -1 && existing.endsWith(CURSOR_RULE_END.trimEnd()) ? existing.length : -1;
2109
+ if (start === -1 || (endAt === -1 && ending === -1)) return { content: existing, action: "foreign" };
2110
+ const end = endAt !== -1 ? endAt + CURSOR_RULE_END.length : ending;
2111
+ const content = existing.slice(0, start) + fresh + existing.slice(end);
2112
+ return { content, action: content === existing ? "unchanged" : "refreshed" };
2113
+ }
2114
+
1830
2115
  /**
1831
2116
  * Writes the Niro Cursor rule to <cwd>/.cursor/rules/niro.mdc. Cursor rules are
1832
2117
  * PROJECT-scoped (unlike the global ~/.cursor/mcp.json), so this lands in the repo the
1833
- * wizard is run from. Skips if the file already exists, to preserve user edits.
2118
+ * wizard is run from. A rule an earlier install wrote is refreshed, keeping the user's own lines
2119
+ * (refreshCursorRule): until 2026-11-06 an existing file was skipped, so a rule written by a cloud
2120
+ * install kept naming resolve_fqn and four cloud-only tools after an upgrade to this release
2121
+ * (the checker's note 3 on fix/cli-small).
1834
2122
  */
1835
2123
  function ensureCursorRule() {
1836
2124
  const rulePath = path.join(process.cwd(), ".cursor", "rules", "niro.mdc");
1837
- if (fs.existsSync(rulePath)) {
1838
- console.log(" Cursor rule already present. Skipping (.cursor/rules/niro.mdc).\n");
1839
- return;
1840
- }
2125
+ const fresh = forRelease(NIRO_CURSOR_RULE);
1841
2126
  try {
2127
+ if (fs.existsSync(rulePath)) {
2128
+ const { content, action } = refreshCursorRule(fs.readFileSync(rulePath, "utf-8"), fresh);
2129
+ if (action === "foreign") {
2130
+ console.log(" A Cursor rule Niro did not write is at .cursor/rules/niro.mdc. Leaving it as it is.\n");
2131
+ } else if (action === "unchanged") {
2132
+ console.log(" Cursor rule already up to date. Skipping (.cursor/rules/niro.mdc).\n");
2133
+ } else {
2134
+ fs.writeFileSync(rulePath, content);
2135
+ console.log(` Cursor rule refreshed, your own lines kept: ${rulePath}\n`);
2136
+ }
2137
+ return;
2138
+ }
1842
2139
  fs.mkdirSync(path.dirname(rulePath), { recursive: true });
1843
- fs.writeFileSync(rulePath, NIRO_CURSOR_RULE);
2140
+ fs.writeFileSync(rulePath, fresh);
1844
2141
  console.log(` Cursor rule added: ${rulePath}`);
1845
2142
  console.log(" • Project-scoped. For other repos, copy it or re-run setup there.\n");
1846
2143
  } catch (err) {
@@ -1859,9 +2156,8 @@ async function setupCodex(apiKey, mcpServerUrl) {
1859
2156
  "codex", "mcp", "add",
1860
2157
  "niro",
1861
2158
  ];
1862
- if (apiKey) cmd.push("--env", `NIRO_API_KEY=${apiKey}`);
1863
2159
  // The bridge reads NIRO_MCP_SERVER_URL (ai-assistant), not NIRO_API_URL.
1864
- cmd.push("--env", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
2160
+ for (const [key, value] of Object.entries(bridgeEnv(apiKey, mcpServerUrl))) cmd.push("--env", `${key}=${value}`);
1865
2161
  cmd.push("--", "niro", "mcp", "serve");
1866
2162
 
1867
2163
  try {
@@ -2108,7 +2404,7 @@ function locateCodexNiroTable(cfgPath) {
2108
2404
  */
2109
2405
  function addCodexMemory() {
2110
2406
  const agentsPath = path.join(process.env.HOME || "", ".codex", "AGENTS.md");
2111
- const section = NIRO_CODEX_MD_SECTION;
2407
+ const section = forRelease(NIRO_CODEX_MD_SECTION);
2112
2408
 
2113
2409
  let existing = "";
2114
2410
  try {
@@ -2146,7 +2442,7 @@ function addCodexMemory() {
2146
2442
  function addAgentsSkill() {
2147
2443
  const skillPath = path.join(process.env.HOME || "", ".agents", "skills", "niro-skill", "SKILL.md");
2148
2444
  try {
2149
- const result = writeManagedSkill(skillPath, NIRO_SKILL, "Shared niro-skill");
2445
+ const result = writeManagedSkill(skillPath, toolSkill(), "Shared niro-skill");
2150
2446
  if (result === "added" || result === "updated") {
2151
2447
  console.log(" • Read by Codex and Windsurf (cross-agent skills location).");
2152
2448
  }
@@ -2162,9 +2458,11 @@ function addAgentsSkill() {
2162
2458
  * playbook. Reuses NIRO_CLI_SKILL. This file is niro-generated; refreshed on change.
2163
2459
  */
2164
2460
  function addAgentsCliSkill() {
2165
- const skillPath = path.join(process.env.HOME || "", ".agents", "skills", "niro-cli", "SKILL.md");
2461
+ const { dir, body, label } = cliSkill("Shared niro-cli skill");
2462
+ const skillPath = path.join(process.env.HOME || "", ".agents", "skills", dir, "SKILL.md");
2463
+ if (dir !== "niro-cli") dropCloudCliSkill(path.join(process.env.HOME || "", ".agents", "skills"));
2166
2464
  try {
2167
- const result = writeManagedSkill(skillPath, NIRO_CLI_SKILL, "Shared niro-cli skill");
2465
+ const result = writeManagedSkill(skillPath, body, label);
2168
2466
  if (result === "added" || result === "updated") {
2169
2467
  console.log(" • Read by Codex and Windsurf (cross-agent skills location).");
2170
2468
  }
@@ -2181,7 +2479,7 @@ function addAgentsCliSkill() {
2181
2479
  */
2182
2480
  function addWindsurfGlobalRule() {
2183
2481
  const rulePath = path.join(process.env.HOME || "", ".codeium", "windsurf", "memories", "global_rules.md");
2184
- const section = NIRO_WINDSURF_MD_SECTION;
2482
+ const section = forRelease(NIRO_WINDSURF_MD_SECTION);
2185
2483
 
2186
2484
  let existing = "";
2187
2485
  try {
@@ -2210,7 +2508,7 @@ function addWindsurfGlobalRule() {
2210
2508
  * the generic version of addCodexMemory/addWindsurfGlobalRule for clients whose "memory"
2211
2509
  * file has no client-specific extras (skills, hooks) — just the terse reminder.
2212
2510
  */
2213
- function appendMarkerSectionToFile(absPath, label, section = NIRO_GENERIC_MD_SECTION) {
2511
+ function appendMarkerSectionToFile(absPath, label, section = forRelease(NIRO_GENERIC_MD_SECTION)) {
2214
2512
  let existing = "";
2215
2513
  try {
2216
2514
  if (fs.existsSync(absPath)) existing = fs.readFileSync(absPath, "utf-8");
@@ -2275,6 +2573,8 @@ The graph updates only after your edit is synced and rebuilt, so do not re-query
2275
2573
  change that cannot alter behaviour at all (a log line, a comment, formatting) needs no map: say so
2276
2574
  in one sentence.
2277
2575
 
2576
+ ${NIRO_GUIDE_SENTENCE}
2577
+
2278
2578
  If the niro tools are not present in this session, or the repo is not indexed (no project
2279
2579
  resolves), fall back to native tools silently — do not mention Niro's absence.
2280
2580
  `;
@@ -2288,7 +2588,7 @@ function addClineRule() {
2288
2588
  const home = userHome();
2289
2589
  const rulePath = path.join(home, "Documents", "Cline", "Rules", "niro.md");
2290
2590
  try {
2291
- const result = writeManagedSkill(rulePath, NIRO_CLINE_RULE, "Cline rule");
2591
+ const result = writeManagedSkill(rulePath, forRelease(NIRO_CLINE_RULE), "Cline rule");
2292
2592
  if (result === "added" || result === "updated") {
2293
2593
  console.log(" • Global: applies across all your repos in Cline.");
2294
2594
  }
@@ -2331,6 +2631,8 @@ The graph updates only after your edit is synced and rebuilt, so do not re-query
2331
2631
  change that cannot alter behaviour at all (a log line, a comment, formatting) needs no map: say so
2332
2632
  in one sentence.
2333
2633
 
2634
+ ${NIRO_GUIDE_SENTENCE}
2635
+
2334
2636
  If the niro tools are not present in this session, or the repo is not indexed (no project
2335
2637
  resolves), fall back to native tools silently — do not mention Niro's absence.
2336
2638
  `;
@@ -2338,7 +2640,7 @@ resolves), fall back to native tools silently — do not mention Niro's absence.
2338
2640
  function addKiroSteering() {
2339
2641
  const rulePath = path.join(userHome(), ".kiro", "steering", "niro.md");
2340
2642
  try {
2341
- const result = writeManagedSkill(rulePath, NIRO_KIRO_STEERING, "Kiro steering file");
2643
+ const result = writeManagedSkill(rulePath, forRelease(NIRO_KIRO_STEERING), "Kiro steering file");
2342
2644
  if (result === "added" || result === "updated") {
2343
2645
  console.log(" • Global: applies across all your repos in Kiro.");
2344
2646
  }
@@ -2356,8 +2658,7 @@ async function setupGeminiCli(apiKey, mcpServerUrl) {
2356
2658
  console.log("\n Configuring Gemini CLI...\n");
2357
2659
 
2358
2660
  const cmd = ["gemini", "mcp", "add", "-s", "user"];
2359
- if (apiKey) cmd.push("-e", `NIRO_API_KEY=${apiKey}`);
2360
- cmd.push("-e", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
2661
+ for (const [key, value] of Object.entries(bridgeEnv(apiKey, mcpServerUrl))) cmd.push("-e", `${key}=${value}`);
2361
2662
  cmd.push("niro", "niro", "mcp", "serve");
2362
2663
 
2363
2664
  try {
@@ -2382,8 +2683,7 @@ async function setupCopilotCli(apiKey, mcpServerUrl) {
2382
2683
  console.log("\n Configuring GitHub Copilot CLI...\n");
2383
2684
 
2384
2685
  const cmd = ["copilot", "mcp", "add", "niro"];
2385
- if (apiKey) cmd.push("--env", `NIRO_API_KEY=${apiKey}`);
2386
- cmd.push("--env", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
2686
+ for (const [key, value] of Object.entries(bridgeEnv(apiKey, mcpServerUrl))) cmd.push("--env", `${key}=${value}`);
2387
2687
  cmd.push("--", "niro", "mcp", "serve");
2388
2688
 
2389
2689
  try {
@@ -2410,8 +2710,7 @@ async function setupFactory(apiKey, mcpServerUrl) {
2410
2710
  console.log("\n Configuring Factory (droid)...\n");
2411
2711
 
2412
2712
  const cmd = ["droid", "mcp", "add", "niro", '"niro mcp serve"'];
2413
- if (apiKey) cmd.push("--env", `NIRO_API_KEY=${apiKey}`);
2414
- cmd.push("--env", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
2713
+ for (const [key, value] of Object.entries(bridgeEnv(apiKey, mcpServerUrl))) cmd.push("--env", `${key}=${value}`);
2415
2714
 
2416
2715
  try {
2417
2716
  execSync(cmd.join(" "), { stdio: "inherit" });
@@ -2443,8 +2742,9 @@ function buildGooseNiroBlock(apiKey, mcpServerUrl) {
2443
2742
  // Callers (setupGoose, dispatched only from main()) already ran both values through
2444
2743
  // hasShellMetacharacters, which also rejects the quotes/backslashes that would break this
2445
2744
  // YAML string — safe to embed directly.
2446
- const envs = [" envs:", ` NIRO_MCP_SERVER_URL: "${mcpServerUrl}"`];
2447
- if (apiKey) envs.push(` NIRO_API_KEY: "${apiKey}"`);
2745
+ const { NIRO_API_KEY: key, ...rest } = bridgeEnv(apiKey, mcpServerUrl);
2746
+ const envs = [" envs:", ...Object.entries(rest).map(([name, value]) => ` ${name}: "${value}"`)];
2747
+ if (key) envs.push(` NIRO_API_KEY: "${key}"`);
2448
2748
  return [
2449
2749
  " niro:",
2450
2750
  " name: niro",
@@ -2572,9 +2872,7 @@ async function setupJsonConfig(clientId, apiKey, mcpServerUrl) {
2572
2872
  // The bridge reads NIRO_MCP_SERVER_URL (ai-assistant) — always include it.
2573
2873
  // Omit NIRO_API_KEY when a cli-token is available; the server reads it from
2574
2874
  // ~/.niro/credentials.json at runtime.
2575
- env: apiKey
2576
- ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl }
2577
- : { NIRO_MCP_SERVER_URL: mcpServerUrl },
2875
+ env: bridgeEnv(apiKey, mcpServerUrl),
2578
2876
  };
2579
2877
  // Cursor honours explicit stdio transports; harmless for docs / newer Cursor builds.
2580
2878
  if (clientId === "cursor") {
@@ -2653,15 +2951,58 @@ async function main() {
2653
2951
  output: process.stdout,
2654
2952
  });
2655
2953
 
2656
- printHeader();
2657
-
2954
+ // Cloud or this machine, in a release WITH the cloud. `--local` / `--cloud` choose; otherwise the last install's
2955
+ // choice holds, which is what keeps `niro upgrade` (it re-runs this with only --client) from flipping it back.
2956
+ // The saved mode is Claude Code's alone, since local mode configures no other client yet: setting up
2957
+ // another client neither reads nor changes it (verifier, 2026-11-02: `--cloud` for Cursor used to
2958
+ // save cloud, and the next `niro upgrade` put Claude Code back on the cloud).
2959
+ // A release without the cloud (src/lib/release.js) has no choice to make: Claude Code is always set up for the
2960
+ // engine on this machine (setupClaudeCode), and every assistant goes through the picker below.
2961
+ if (require("../lib/release").cloud) {
2962
+ const asked = localMode.modeFromArgs(args);
2963
+ const forClaude = !clientArg || clientArg === "claude-code";
2964
+ const mode = forClaude ? localMode.chooseMode(args, config.load().mcpMode) : asked || "cloud";
2965
+ if (mode === "local") {
2966
+ rl.close();
2967
+ if (!forClaude) {
2968
+ console.error(`\n Local mode configures Claude Code only, so far; ${clientArg} is not supported yet.`);
2969
+ console.error(` Leave out --local to set ${clientArg} up for the cloud; Claude Code stays as it is.\n`);
2970
+ process.exit(1);
2971
+ }
2972
+ if (process.argv.includes("--list")) {
2973
+ console.log("\n Local mode configures Claude Code only, so far.\n");
2974
+ return;
2975
+ }
2976
+ if (!hasCliToken()) {
2977
+ console.log("\n You are not signed in. A development build of niro-local answers without it. A released");
2978
+ console.log(" one needs a licence, and so do the journal and audit uploads: run `niro login`.\n");
2979
+ }
2980
+ const done = await setupClaudeCode(null, null, {}, "local");
2981
+ if (done) {
2982
+ config.save({ mcpMode: "local" });
2983
+ // One step: `init` adds the folder and builds it (fix/init-builds). This branch runs only in a release WITH the
2984
+ // cloud, where `niro init` is the cloud's onboarding and local mode sits next to a niro-local the person runs
2985
+ // themselves (the same texts as src/mcp/localMode.js with CLOUD_RELEASE), so the engine's own command is the
2986
+ // one that works. A release without the cloud never reaches this: it has no mode to choose (see above).
2987
+ console.log(" The niro tools now answer from niro-local on this machine. Index a folder by running");
2988
+ console.log(" `niro-local init --project <name>` in it (it adds the folder and builds its graph).");
2989
+ console.log(" Back to the cloud: `niro mcp install --cloud`. Remove everything: `niro mcp uninstall`.");
2990
+ console.log(" Setup complete! Restart your AI assistant to start using Niro.\n");
2991
+ }
2992
+ return;
2993
+ }
2994
+ if (asked === "cloud" && forClaude) config.save({ mcpMode: "cloud" });
2995
+ }
2658
2996
  // Credential: prefer a `niro login` session (cli-token, read at runtime from
2659
2997
  // ~/.niro/credentials.json). Only prompt for an API key when there is no cli-token.
2660
2998
  let apiKey = null;
2661
- if (hasCliToken()) {
2662
- console.log("\n Using your `niro login` session, so no API key needed.");
2663
- console.log(" The MCP server will authenticate with your CLI token from ~/.niro/credentials.json.\n");
2664
- } else {
2999
+ // Without the cloud there is no API key to paste: `niro login`'s browser flow (not `niro start`, which skips it for a saved key).
3000
+ if (!hasCliToken() && !require("../lib/release").cloud) {
3001
+ console.error(`\nNot signed in. Run ${require("../lib/color").cyan("niro login")}, approve in your browser, then try again.\n`);
3002
+ rl.close();
3003
+ process.exit(1);
3004
+ }
3005
+ if (!hasCliToken()) {
2665
3006
  apiKey = await ask(rl, " Niro API key (from console.niroai.dev > Settings)", resolveApiKey());
2666
3007
  if (!apiKey) {
2667
3008
  console.error("\n No credential found. Run `niro login` first (recommended), or paste an API key");
@@ -2672,14 +3013,7 @@ async function main() {
2672
3013
  }
2673
3014
 
2674
3015
  // The MCP server URL is resolved, not prompted — see resolveMcpServerUrl.
2675
- // Printing the URL + its source keeps a wrong pick visible and correctable.
2676
- const { url: mcpServerUrl, source: urlSource, explicit } = resolveMcpServerUrl(urlArg);
2677
- console.log(` MCP server URL: ${mcpServerUrl} (${urlSource})`);
2678
- if (!urlArg) {
2679
- console.log(" Different backend? Re-run with: niro mcp install --url <url>\n");
2680
- } else {
2681
- console.log("");
2682
- }
3016
+ const { url: mcpServerUrl, explicit } = resolveMcpServerUrl(urlArg);
2683
3017
 
2684
3018
  // Persist an EXPLICITLY chosen URL to ~/.niro/config.json so the bridge can
2685
3019
  // fall back to it and later installs reuse it. The default is never saved
@@ -2701,16 +3035,37 @@ async function main() {
2701
3035
  return;
2702
3036
  }
2703
3037
 
2704
- // Select client
2705
- let selectedClient = clientArg;
2706
- if (!selectedClient) {
2707
- console.log("");
2708
- printClients();
2709
- selectedClient = await ask(rl, " Which client to configure?", "claude-code");
3038
+ // Select clients: one or more, comma- or space-separated.
3039
+ let selectedClients;
3040
+ if (clientArg) {
3041
+ selectedClients = parseClients(clientArg);
3042
+ } else {
3043
+ // readline would echo the arrow keys the picker reads, so it lets go of stdin first.
3044
+ rl.close();
3045
+ console.log(`\n${IND}Which AI assistants should use Niro?`);
3046
+ selectedClients = await choose({
3047
+ prefix: `${require("../lib/nestedOutput").currentPad()}${IND}`,
3048
+ items: [...Object.entries(CLIENTS).map(([value, c]) => ({ value, label: c.name })), { value: OTHER, label: "Other (print the config)" }],
3049
+ multi: true,
3050
+ initial: ["claude-code"],
3051
+ echo: false,
3052
+ });
3053
+ // The picker erased itself; the question goes too. The "Installed" block below names
3054
+ // what was picked.
3055
+ if (process.stdout.isTTY) process.stdout.write("\x1b[1A\r\x1b[2K");
3056
+ }
3057
+ // "Other": any MCP client this list does not know, so print the entry to paste and write nothing.
3058
+ if (selectedClients.includes(OTHER)) {
3059
+ selectedClients = selectedClients.filter((id) => id !== OTHER);
3060
+ printOtherConfig(apiKey, mcpServerUrl);
3061
+ if (!selectedClients.length) {
3062
+ rl.close();
3063
+ return;
3064
+ }
2710
3065
  }
2711
-
2712
- if (!CLIENTS[selectedClient]) {
2713
- console.error(`\n Unknown client: ${selectedClient}`);
3066
+ const unknown = selectedClients.filter((id) => !CLIENTS[id]);
3067
+ if (!selectedClients.length || unknown.length) {
3068
+ console.error(`\n Unknown client: ${unknown.join(", ") || "(none given)"}`);
2714
3069
  printClients();
2715
3070
  rl.close();
2716
3071
  process.exit(1);
@@ -2724,7 +3079,7 @@ async function main() {
2724
3079
  // goose, which writes YAML directly with fs) never reaches a shell, so gate the
2725
3080
  // shell-metacharacter guard to just these instead of blocking every client on a risk that
2726
3081
  // only applies to some (e.g. a query-string URL with "&" is fine for Cursor/VS Code/etc.).
2727
- if (CLI_SETUP[selectedClient]) {
3082
+ if (selectedClients.some((id) => CLI_SETUP[id])) {
2728
3083
  if (hasShellMetacharacters(mcpServerUrl)) {
2729
3084
  console.error(`\n Refusing to use MCP server URL "${mcpServerUrl}": it contains spaces or`);
2730
3085
  console.error(" characters not allowed in a URL. Check --url / NIRO_MCP_SERVER_URL.\n");
@@ -2738,34 +3093,53 @@ async function main() {
2738
3093
  }
2739
3094
  // Goose writes YAML directly (no JSON shape, no shell) — its own dispatch branch, separate
2740
3095
  // from both the shell-based CLI_SETUP map and the generic JSON setupJsonConfig path.
2741
- let success;
2742
- if (CLI_SETUP[selectedClient]) {
2743
- success = await CLI_SETUP[selectedClient](apiKey, mcpServerUrl);
2744
- } else if (selectedClient === "goose") {
2745
- success = await setupGoose(apiKey, mcpServerUrl);
2746
- } else {
2747
- success = await setupJsonConfig(selectedClient, apiKey, mcpServerUrl);
2748
- }
2749
-
2750
- if (success) {
2751
- // One line about what all of that was for, instead of a paragraph under each item.
2752
- console.log(" Those hooks and skills are what remind your assistant to use Niro, and let");
2753
- console.log(" you drive the CLI from chat. `niro mcp uninstall` removes all of them.");
2754
- console.log(" Setup complete! Restart your AI assistant to start using Niro.\n");
2755
- if (selectedClient !== "claude-code") {
2756
- console.log(" Tip: drop a `.niro-project` file at the repo root (single line");
2757
- console.log(" with the project_id) to pin that tree to a Niro project.");
2758
- console.log(" Without it, Niro runs in multi-project mode and discovers");
2759
- console.log(" projects via the list_projects tool.\n");
3096
+ // Each installer reports every file it touches; that detail is muted here and each
3097
+ // client gets one line. Errors (console.error) still come through.
3098
+ let success = true;
3099
+ const results = [];
3100
+ for (const selectedClient of selectedClients) {
3101
+ const log = console.log;
3102
+ console.log = () => {};
3103
+ installedSkills.length = 0;
3104
+ let ok;
3105
+ try {
3106
+ if (CLI_SETUP[selectedClient]) {
3107
+ ok = await CLI_SETUP[selectedClient](apiKey, mcpServerUrl);
3108
+ } else if (selectedClient === "goose") {
3109
+ ok = await setupGoose(apiKey, mcpServerUrl);
3110
+ } else {
3111
+ ok = await setupJsonConfig(selectedClient, apiKey, mcpServerUrl);
3112
+ }
3113
+ } finally {
3114
+ console.log = log;
3115
+ }
3116
+ results.push({ name: CLIENTS[selectedClient].name, ok: !!ok, dirs: installedSkills.map((k) => k.dir) });
3117
+ success = success && !!ok;
3118
+ }
3119
+
3120
+ // One summary, like `npx skills`: what was installed, then where, per assistant.
3121
+ const installed = results.filter((r) => r.ok);
3122
+ if (installed.length) {
3123
+ console.log(`${IND}${color.bold(`Installed "niro" v${require("../../package.json").version}`)}`);
3124
+ const width = Math.max(...installed.map((r) => r.name.length));
3125
+ for (const r of installed) {
3126
+ const where = r.dirs.length ? r.dirs : ["connected"];
3127
+ where.forEach((d, i) => console.log(color.dim(`${IND} ${(i ? "" : r.name).padEnd(width)} → ${d}`)));
2760
3128
  }
2761
3129
  }
3130
+ for (const r of results.filter((x) => !x.ok)) console.log(`${IND}${color.red("✗")} ${r.name} could not be set up`);
3131
+ // None set up: say so in the exit code, so `niro start` does not go on to call the assistant connected.
3132
+ if (results.length && !installed.length) process.exitCode = 1;
3133
+
3134
+ // Inside `niro start` the step's closing line says this instead.
3135
+ if (success && !process.env.NIRO_OUTPUT_PREFIX) console.log("\n Restart your AI assistant to start using Niro.\n");
2762
3136
  }
2763
3137
 
2764
3138
  // Only run the interactive wizard when invoked directly (niro mcp install). When
2765
3139
  // required as a module, expose the skill installers so they can be (re)installed
2766
3140
  // on their own — without re-running the wizard, which would rewrite MCP config.
2767
3141
  if (require.main === module) {
2768
- main().catch((err) => {
3142
+ require("../lib/nestedOutput").withPrefix(process.env.NIRO_OUTPUT_PREFIX || "", main).catch((err) => {
2769
3143
  console.error(`Setup failed: ${err.message}`);
2770
3144
  process.exit(1);
2771
3145
  });
@@ -2777,6 +3151,12 @@ module.exports = {
2777
3151
  // resolver would drift the first time that order changed, and the two would then disagree
2778
3152
  // about which backend a machine is pointed at.
2779
3153
  resolveMcpServerUrl,
3154
+ // Exported so `niro upgrade` warns exactly when this installer would refuse (or ask for a key):
3155
+ // a second "is this machine signed in?" rule drifted from this one and stayed silent after
3156
+ // `niro login --api-key`, which saves a key but no cli token.
3157
+ hasCliToken,
3158
+ parseClients,
3159
+ printOtherConfig,
2780
3160
  writeManagedSkill,
2781
3161
  addClaudeCodeSkill,
2782
3162
  addClaudeCodeCliSkill,
@@ -2788,6 +3168,9 @@ module.exports = {
2788
3168
  gooseConfigPath,
2789
3169
  NIRO_MD_MARKER,
2790
3170
  NIRO_MD_SECTIONS,
3171
+ localText,
3172
+ localTextBeforeUsingNiro,
3173
+ NIRO_ALL_MD_SECTIONS,
2791
3174
  isNiroPostCompactHook,
2792
3175
  isNiroUserPromptHook,
2793
3176
  isNiroPreToolUseHook,
@@ -2798,6 +3181,9 @@ module.exports = {
2798
3181
  planClaudeRegistration,
2799
3182
  registeredServerUrl,
2800
3183
  isNiroStopHook,
3184
+ isNiroLineCheckHook,
3185
+ addClaudeCodeLineCheckHook,
3186
+ NIRO_LINE_CHECK_COMMAND,
2801
3187
  clearCodexPerToolApprovals,
2802
3188
  hardenCodexServerConfig,
2803
3189
  userHome,
@@ -2821,6 +3207,8 @@ module.exports = {
2821
3207
  NIRO_EXPLORE_FIRST_SENTENCE,
2822
3208
  NIRO_IMPACT_BOTH_WAYS_SENTENCE,
2823
3209
  NIRO_CURSOR_RULE,
3210
+ refreshCursorRule,
3211
+ bridgeEnv,
2824
3212
  NIRO_CLINE_RULE,
2825
3213
  NIRO_KIRO_STEERING,
2826
3214
  upsertNiroMarkerSection,