@niroai/niro 0.17.0 → 1.0.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 +136 -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 +6 -2
  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
@@ -0,0 +1,254 @@
1
+ /**
2
+ * `niro guide`: read the documentation that ships in the package, from the terminal, offline.
3
+ *
4
+ * niro guide every page, grouped as docs/index.md groups them, one line each
5
+ * niro guide <topic> one page (by name, title or word); a short list when several match
6
+ * niro guide --search <words> every page holding the words, with the lines that hold them
7
+ * --json one JSON object instead, for an agent; --no-pager prints straight out
8
+ *
9
+ * Not `niro docs`, which is the engine's own route (src/commands/engineRoutes.js). Long output goes
10
+ * through a pager only when it is printed to a terminal and is taller than the window. A topic no page
11
+ * name, title or line matches shows what a search finds instead; only when that finds nothing too, and
12
+ * for a search with no match, does it exit with 1. Every --json answer has the same keys (envelope).
13
+ */
14
+ "use strict";
15
+
16
+ const { spawnSync } = require("child_process");
17
+ const guideLib = require("../lib/guide");
18
+ const { search } = require("../lib/guideSearch");
19
+
20
+ const MAX_RESULTS = 10;
21
+ /** How many search results a topic with no match shows in their place. */
22
+ const FALLBACK_RESULTS = 3;
23
+
24
+ /** The pager to run: $PAGER, else less (more on Windows). */
25
+ function pagerCommand(env = process.env, platform = process.platform) {
26
+ return env.PAGER || (platform === "win32" ? "more" : "less");
27
+ }
28
+
29
+ /**
30
+ * Show `text` through the pager. Returns false when the pager could not be started, so the caller
31
+ * prints the text itself. `less` gets FRX unless LESS is set: quit when it fits, keep colour, leave the
32
+ * text on the screen afterwards. $PAGER runs through a shell, as git and man run it: the value is the
33
+ * person's own setting, so this runs nothing they could not run themselves.
34
+ *
35
+ * Ctrl-C while the pager shows reaches both processes. niro ignores it until the pager ends, as git does,
36
+ * so the pager handles it and the terminal is left as the pager leaves it. The listener is removed
37
+ * straight after: a signal that came in meanwhile is dropped, not delivered later.
38
+ */
39
+ function runPager(text, { env = process.env, platform = process.platform, spawn = spawnSync } = {}) {
40
+ const command = pagerCommand(env, platform);
41
+ const childEnv = { ...env };
42
+ if (childEnv.LESS === undefined) childEnv.LESS = "FRX";
43
+ const ignore = () => {};
44
+ process.on("SIGINT", ignore);
45
+ let r;
46
+ try {
47
+ r = spawn(command, { shell: true, input: `${text}\n`, stdio: ["pipe", "inherit", "inherit"], env: childEnv });
48
+ } finally {
49
+ process.removeListener("SIGINT", ignore);
50
+ }
51
+ if (r.error) return false;
52
+ // 127 from sh, 9009 from cmd.exe: the pager was not found, and nothing was shown.
53
+ if (r.status === 127 || r.status === 9009) return false;
54
+ return true;
55
+ }
56
+
57
+ /** Print `text`, through the pager when it is for a terminal and taller than the window. */
58
+ function show(text, io) {
59
+ const tall = text.split("\n").length > Math.max(1, (io.rows || 24) - 1);
60
+ if (io.isTTY && io.pager !== false && tall && io.runPager(text)) return;
61
+ io.write(`${text}\n`);
62
+ }
63
+
64
+ function cut(line, io) {
65
+ if (!io.isTTY || !io.columns || line.length <= io.columns) return line;
66
+ return `${line.slice(0, Math.max(10, io.columns - 3))}...`;
67
+ }
68
+
69
+ function pageLines(pages, io) {
70
+ const width = Math.min(34, Math.max(...pages.map((p) => p.page.length)) + 2);
71
+ return pages.map((p) => cut(` ${p.page.padEnd(width)}${p.summary}`, io));
72
+ }
73
+
74
+ function listText(guide, io) {
75
+ const count = [...guide.pages.values()].filter((p) => p.listed).length;
76
+ const out = [
77
+ `Niro documentation: ${count} pages, on this computer.`,
78
+ " niro guide <page> read a page",
79
+ " niro guide --search <words> search every page",
80
+ ];
81
+ if (guide.start) out.push(`Start with: niro guide ${guide.start}`);
82
+ const all = guide.groups.flatMap((g) => g.pages.map((p) => guide.pages.get(p)));
83
+ const width = Math.min(34, Math.max(0, ...all.map((p) => p.page.length)) + 2);
84
+ for (const g of guide.groups) {
85
+ out.push("", io.bold(g.name));
86
+ for (const name of g.pages) {
87
+ const p = guide.pages.get(name);
88
+ out.push(cut(` ${p.page.padEnd(width)}${p.summary}`, io));
89
+ }
90
+ }
91
+ return out.join("\n");
92
+ }
93
+
94
+ /** Every --json answer: the same keys whatever the form, so an agent reads one shape. */
95
+ function envelope(mode, fields = {}) {
96
+ return {
97
+ mode, // "list" | "page" | "choose" | "search" | "none"
98
+ query: null,
99
+ note: null,
100
+ start: null,
101
+ groups: [],
102
+ page: null,
103
+ matches: [],
104
+ match: null, // for "search": "phrase", "words", "pages" or "closest"
105
+ results: [],
106
+ total: 0,
107
+ error: null,
108
+ ...fields,
109
+ };
110
+ }
111
+
112
+ function writeJson(io, value) {
113
+ io.write(`${JSON.stringify(value, null, 2)}\n`);
114
+ }
115
+
116
+ function brief(p) {
117
+ return { page: p.page, title: p.title, summary: p.summary || p.title };
118
+ }
119
+
120
+ function listJson(guide) {
121
+ const groups = guide.groups.map((g) => ({ name: g.name, pages: g.pages.map((n) => brief(guide.pages.get(n))) }));
122
+ return envelope("list", { start: guide.start, groups, total: groups.reduce((n, g) => n + g.pages.length, 0) });
123
+ }
124
+
125
+ /** Nothing to show: one sentence on stderr (or a JSON answer saying it), exit code 1. */
126
+ function nothing(io, json, query, message) {
127
+ if (json) writeJson(io, envelope("none", { query, error: message }));
128
+ else io.writeErr(`${message}\n`);
129
+ io.setExitCode(1);
130
+ }
131
+
132
+ function searchHeading(found, typed) {
133
+ const n = found.results.length;
134
+ const query = found.searched ? found.searched : typed;
135
+ const from = found.searched ? ` (from "${typed}")` : "";
136
+ if (found.match === "closest" && found.byPage) return `No page holds every word of "${query}"${from}; closest matches:`;
137
+ if (found.match === "closest") return `No line holds every word of "${query}"${from}. The closest ${n === 1 ? "page" : `${n} pages`}:`;
138
+ if (found.match === "pages") return `No line holds every word of "${query}"${from}; ${n === 1 ? "this page holds" : `${n} pages hold`} them all, best first:`;
139
+ return `${n} page${n === 1 ? " matches" : "s match"} "${query}"${from}.`;
140
+ }
141
+
142
+ function searchLines(results, io, limit) {
143
+ const out = [];
144
+ for (const r of results.slice(0, limit)) {
145
+ out.push("", `${io.bold(`niro guide ${r.page}`)} ${r.title}`);
146
+ for (const l of r.lines) out.push(cut(` ${l.text}`, io));
147
+ if (r.total > r.lines.length) out.push(` (and ${r.total - r.lines.length} more line${r.total - r.lines.length === 1 ? "" : "s"})`);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function showPage(p, io, json) {
153
+ if (p.unreadable) return nothing(io, json, p.page, `The page ${p.page} cannot be read (${p.unreadable}). Reinstall niro with npm install -g @niroai/niro.`);
154
+ if (json) {
155
+ writeJson(io, envelope("page", {
156
+ query: p.page,
157
+ page: { page: p.page, title: p.title, text: guideLib.render(p.text), links: guideLib.linkedPages(p.text) },
158
+ total: 1,
159
+ }));
160
+ return;
161
+ }
162
+ show(guideLib.render(p.text, { bold: io.bold }), io);
163
+ }
164
+
165
+ function showTopic(guide, topic, io, json) {
166
+ const found = guideLib.find(guide, topic);
167
+ if (found.page) return showPage(found.page, io, json);
168
+ if (found.matches.length) {
169
+ if (json) return writeJson(io, envelope("choose", { query: topic, matches: found.matches.map(brief), total: found.matches.length }));
170
+ const lines = [`${found.matches.length} pages match "${topic}". Run one of:`, ...pageLines(found.matches.map(brief), io).map((l) => l.replace(/^ /, " niro guide "))];
171
+ return show(lines.join("\n"), io);
172
+ }
173
+ // No page is called that: show what a search finds, rather than only pointing at --search. A topic of
174
+ // one- and two-letter words ("a", "is") is in nearly every page, so it is not searched for.
175
+ if (guideLib.keyWords(topic).every((w) => w.length <= 2)) {
176
+ return nothing(io, json, topic, `No page matches "${topic}". Give a longer topic, or see every page with: niro guide`);
177
+ }
178
+ const searched = search(guide, topic);
179
+ if (!searched.results.length) return nothing(io, json, topic, `No page matches "${topic}". See every page with: niro guide`);
180
+ // Worded from how the search matched: "come closest" when no line holds every word typed.
181
+ const note = `No page is called "${topic}". ${searched.match === "closest" ? "These pages come closest." : "These pages mention it."}`;
182
+ if (json) return writeJson(io, envelope("search", { query: topic, note, match: searched.match, results: searched.results, total: searched.results.length }));
183
+ const more = searched.results.length > FALLBACK_RESULTS ? ` (${searched.results.length} in all: niro guide --search ${topic})` : "";
184
+ show([`${note}${more}`, ...searchLines(searched.results, io, FALLBACK_RESULTS)].join("\n"), io);
185
+ }
186
+
187
+ function showSearch(guide, query, io, json) {
188
+ if (!query.trim()) return nothing(io, json, "", "Give the words to search for: niro guide --search <words>");
189
+ const found = search(guide, query);
190
+ if (!found.results.length) return nothing(io, json, query, `No page matches "${query}". Try fewer words, or see every page with: niro guide`);
191
+ if (json) return writeJson(io, envelope("search", { query, match: found.match, results: found.results, total: found.results.length }));
192
+ const out = [searchHeading(found, query), ...searchLines(found.results, io, MAX_RESULTS)];
193
+ if (found.results.length > MAX_RESULTS) out.push("", `and ${found.results.length - MAX_RESULTS} more pages (--json lists them all).`);
194
+ show(out.join("\n"), io);
195
+ }
196
+
197
+ /**
198
+ * A reader that stops early (`niro guide x | head`) closes the pipe: that is not an error, so niro ends
199
+ * quietly with 0 rather than printing Node's crash for the write it could not finish.
200
+ */
201
+ function quietOnClosedPipe(stream = process.stdout, exit = process.exit) {
202
+ if (stream.listenerCount("error") && stream._niroGuidePipe) return;
203
+ stream._niroGuidePipe = true;
204
+ stream.on("error", (err) => {
205
+ if (err && err.code === "EPIPE") return exit(0);
206
+ throw err;
207
+ });
208
+ }
209
+
210
+ /**
211
+ * The command. `words` are the words typed after `niro guide`; `opts` carries json, search, pager.
212
+ * `io` replaces the terminal in tests.
213
+ */
214
+ function guide(words = [], opts = {}, io = {}) {
215
+ const color = require("../lib/color");
216
+ const json = Boolean(opts.json);
217
+ if (!io.write) quietOnClosedPipe();
218
+ const term = {
219
+ write: (s) => process.stdout.write(s),
220
+ writeErr: (s) => process.stderr.write(s),
221
+ setExitCode: (c) => { process.exitCode = c; },
222
+ isTTY: Boolean(process.stdout.isTTY),
223
+ rows: process.stdout.rows,
224
+ columns: process.stdout.columns,
225
+ runPager: (text) => runPager(text),
226
+ bold: color.bold,
227
+ ...io,
228
+ };
229
+ term.pager = opts.pager !== false && !json;
230
+ const loaded = guideLib.load(opts.docsDir);
231
+ const query = words.join(" ").trim();
232
+ if (opts.search) return showSearch(loaded, query, term, json);
233
+ if (!query) {
234
+ if (json) return writeJson(term, listJson(loaded));
235
+ return show(listText(loaded, term), term);
236
+ }
237
+ return showTopic(loaded, query, term, json);
238
+ }
239
+
240
+ /**
241
+ * Add `niro guide` to the command line. `run` is src/index.js's error wrapper. Registered after every other
242
+ * command, in one line of src/index.js, so the line numbers the docs pages cite in that file stay where they are.
243
+ */
244
+ function register(program, run) {
245
+ program
246
+ .command("guide [topic...]")
247
+ .description("Read Niro's documentation in the terminal, offline. No topic lists the pages; a page name, title or word shows that page.")
248
+ .option("--search", "search every page for the words given, and show the lines that hold them")
249
+ .option("--json", "emit machine-readable JSON")
250
+ .option("--no-pager", "print straight out, even to a terminal")
251
+ .action((topic, opts) => run(async () => guide(topic, { ...opts })));
252
+ }
253
+
254
+ module.exports = { guide, register, runPager, pagerCommand, envelope, quietOnClosedPipe };
@@ -41,16 +41,17 @@ const color = require("../lib/color");
41
41
  const watchDaemon = require("../lib/watchDaemon");
42
42
  const { login } = require("./login");
43
43
  const { watchStatus } = require("./build");
44
- const { currentIndent, withIndent } = require("../lib/nestedOutput");
44
+ const { currentPad, withIndent } = require("../lib/nestedOutput");
45
45
 
46
46
  /** Depth as spaces, for the \r-written lines that bypass withIndent's console wrappers. */
47
47
  function nestPad() {
48
- return " ".repeat(currentIndent());
48
+ return currentPad();
49
49
  }
50
50
  const { fetchPreview, buildCandidates } = require("./edit");
51
51
  const { resolveProjectId } = require("./_resolveProject");
52
52
  const { projectsToRows } = require("./projects");
53
53
  const { isSecretKey, looksSecretValue } = require("../lib/secret");
54
+ const cloudGuard = require("../lib/cloudGuard");
54
55
 
55
56
  const step = (n) => color.bold(color.cyan(n));
56
57
  const ok = (s) => color.green(s);
@@ -66,6 +67,9 @@ async function init(pathArg, opts = {}) {
66
67
  const nested = !!opts.nested;
67
68
  const substep = (n) => (nested ? step("●") : step(n));
68
69
  const targetDir = resolveTargetDir(pathArg);
70
+ // Before anything else, including the flag checks: onboarding a folder the engine on this
71
+ // machine already indexes would upload its source and index it twice.
72
+ if (await cloudGuard.refuseIfIndexedLocally(targetDir, { command: "init" })) return;
69
73
  // Validate flag values up-front so a bad --auto-build/--confirm-build/--quiet-period
70
74
  // fails BEFORE any server-side mutation (otherwise a typo leaves an orphan project).
71
75
  parseOnOff(opts.autoBuild);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `niro init [path]` in a release without the cloud: add one folder to the local engine and build
3
+ * it, on a machine `niro start` has already set up.
4
+ *
5
+ * `niro start` is the once-per-machine setup (sign-in and terminal approval, the plan, the engine,
6
+ * the assistant, the watch daemon). Every folder after the first only needs adding and building,
7
+ * so this does just that and stops with a pointer to `niro start` when the machine is not set up.
8
+ * An already indexed folder is built again. The line limit is the engine's rule (`planUsage.refusesBuild`):
9
+ * at or over it, a folder that adds new lines goes to the console's /limit page instead of building,
10
+ * while a folder already counted (a rebuild, a temporary copy, a service inside an indexed root) builds.
11
+ *
12
+ * `niro build` is the same with `rebuildOnly`: it rebuilds a folder already added and refuses one
13
+ * that is not, so each command keeps the engine's meaning (init adds, build rebuilds).
14
+ *
15
+ * Adding and building is one engine command, the engine's own `init`, which builds the folder it adds (Niraj,
16
+ * 2026-11-06: "niro-local init should also build it"), so the folder is built once. `niro init` takes every option
17
+ * of the engine's `init` with the same meaning (`--project`, `--branch`, `--path`, `--base-url`, `--force`): "niro
18
+ * init should also support --project <name> the same way niro-local does. support all option of niro-local init to
19
+ * niro init". So `niro init --project niro` in a second repository joins the project "niro" and builds it.
20
+ */
21
+ const path = require("path");
22
+ const color = require("../lib/color");
23
+ const engine = require("../lib/engine");
24
+ const lease = require("../lib/lease");
25
+ const localOnboard = require("../lib/localOnboard");
26
+ const planUsage = require("../lib/planUsage");
27
+ const progressBar = require("../lib/progressBar");
28
+ const start = require("./start");
29
+
30
+ const n = (x) => x.toLocaleString("en-US");
31
+
32
+ function stop(message) {
33
+ console.error(message);
34
+ process.exitCode = 1;
35
+ }
36
+
37
+ async function initLocal(pathArg, {
38
+ hasCredentials = start.hasCredentials, resolveEngine = engine.resolve, currentClaims = lease.currentClaims,
39
+ linesUsed = planUsage.linesUsed, alreadyCounted = planUsage.alreadyCounted, onboard = localOnboard,
40
+ openUrl = require("../lib/browser").open, rebuildOnly = false, buildArgs = [], initOptions = {},
41
+ } = {}) {
42
+ if (!hasCredentials()) return stop(`Not signed in. Run ${color.cyan("niro start")} once to sign in and set up this machine.`);
43
+ if (!resolveEngine()) return stop(`The local engine is not installed. Run ${color.cyan("niro start")} once to set up this machine.`);
44
+ const { path: pathOption, ...engineOptions } = initOptions;
45
+ if (pathArg && pathOption && path.resolve(pathArg) !== path.resolve(pathOption)) {
46
+ return stop(`Two folders were given (${pathArg} and --path ${pathOption}). Give one.`);
47
+ }
48
+ const dir = path.resolve(pathOption || pathArg || ".");
49
+ const problem = start.describeProblem(dir);
50
+ if (problem) return stop([problem.headline, ...problem.detail].join("\n"));
51
+
52
+ const known = await onboard.worktreeFor(dir);
53
+ if (!known && rebuildOnly) return stop(`This folder is not added yet. Run ${color.cyan("niro init")} to add it and build it.`);
54
+
55
+ const claims = await currentClaims().catch(() => null);
56
+ const before = await linesUsed();
57
+ if (await planUsage.refusesBuild(claims, before, dir, { counted: alreadyCounted })) {
58
+ const url = planUsage.limitUrl(before, claims.max_lines);
59
+ console.error(`Not built: this machine already holds ${n(before)} lines of code, over the ${n(claims.max_lines)} line limit.`);
60
+ console.error(`Choose how to continue: ${color.cyan(url)}`);
61
+ console.error(`Or see the options with ${color.cyan("niro plan")}, or free lines by stopping a folder: ${color.cyan("niro remove --path <folder>")}`);
62
+ openUrl(url);
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+
67
+ console.log(`${known && !engineOptions.project ? "Rebuilding" : "Building"} the graph for ${dir}`);
68
+ const bar = progressBar.create();
69
+ const onProgress = (d, total) => bar.update(d, total);
70
+ let built;
71
+ try {
72
+ // `niro build` rebuilds what is added; `niro init` asks the engine's `init`, which adds the folder if it is not
73
+ // yet and builds it, and refuses a folder it cannot index before writing anything, in its own words.
74
+ built = rebuildOnly
75
+ ? await onboard.build(dir, onProgress, { extraArgs: buildArgs })
76
+ : await onboard.initAndBuild(dir, onProgress, engineOptions);
77
+ } catch (err) {
78
+ bar.stop();
79
+ // A refusal such as "not in project X" is on standard output (`niro build --project X`).
80
+ return stop(((err && (err.refused ? err.message : (err.stderr || err.stdout || err.message))) || String(err)).replace(/^error:\s*/, ""));
81
+ }
82
+ bar.stop();
83
+ // What the engine's init said about the folder (the project it joined, a --branch it ignored), then the count.
84
+ for (const line of built.said || []) console.log(line);
85
+ // No build report: the engine changed nothing and said why (a subfolder of an added folder, named with --project).
86
+ if (built.files == null && built.lines == null && built.said && built.said.length) return;
87
+ const parts = [built.files != null && `${n(built.files)} files`, built.lines != null && `${n(built.lines)} lines`].filter(Boolean);
88
+ console.log(`${color.green("✓")} Graph built: ${parts.join(", ") || "done"} in ${built.seconds} s`);
89
+ const used = await linesUsed();
90
+ const plan = planUsage.describe(claims, used);
91
+ if (plan) console.log(plan);
92
+ const noRoom = planUsage.noRoomNote(claims, used);
93
+ if (noRoom) console.log(color.yellow(`${noRoom} Run niro plan to see your options, or free lines with niro remove --path <folder>.`));
94
+ }
95
+
96
+ module.exports = { initLocal };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `niro journal-send` — hand the local engine's record of what it was asked to Niro.
3
+ *
4
+ * The engine answers an assistant's questions on this machine, so unlike a hosted session
5
+ * none of those calls are visible to us. The engine keeps its own record and this sends it, so
6
+ * the same question ("which tools get used, which fail, where does an assistant fall back to
7
+ * reading files") can be answered for local sessions too.
8
+ *
9
+ * A command of its own so the path can be run and watched by hand; in normal use the watcher
10
+ * calls the same library, next to the audit upload.
11
+ *
12
+ * Prints counts and never a payload.
13
+ */
14
+ const journalUpload = require("../lib/journalUpload");
15
+ const color = require("../lib/color");
16
+
17
+ async function journalSend(opts = {}) {
18
+ const limit = Number(opts.limit) || 20;
19
+ console.log(`Sending the local engine's MCP journal to ${journalUpload.assistantUrl()}`);
20
+
21
+ const result = await journalUpload.uploadPending({ limit });
22
+
23
+ if (result.sent) {
24
+ console.log(` ${result.sent} session(s) sent, ${result.calls} tool call(s)`);
25
+ } else if (!result.failed && !result.unmasked) {
26
+ // A session still in use is held back by the engine, so "nothing waiting" is the normal
27
+ // answer in the middle of a task rather than a sign that nothing was recorded.
28
+ console.log(" nothing waiting");
29
+ }
30
+
31
+ if (result.unmasked) {
32
+ // Sessions from an engine older than on-machine masking: never sent unmasked, so they wait.
33
+ console.log(color.yellow(
34
+ ` ${result.unmasked} session(s) not sent: the local engine is too old to mask secrets. Run \`niro upgrade\`.`
35
+ ));
36
+ }
37
+
38
+ if (result.failed) {
39
+ console.log(color.yellow(` ${result.failed} session(s) could not be sent:`));
40
+ for (const failure of result.failures.slice(0, 5)) console.log(` ${failure}`);
41
+ if (result.failures.length > 5) {
42
+ console.log(` ... and ${result.failures.length - 5} more`);
43
+ }
44
+ }
45
+ }
46
+
47
+ module.exports = { journalSend };
@@ -150,7 +150,8 @@ async function loginWithDeviceFlow({ apiUrl }) {
150
150
  const deviceCode = resp.device_code || resp.deviceCode;
151
151
  const verificationUri = resp.verification_uri_complete || resp.verificationUriComplete
152
152
  || resp.verification_uri || resp.verificationUri;
153
- const interval = Number(resp.interval || DEFAULT_POLL_INTERVAL_SECONDS);
153
+ // `??`, not `||`: a server may legitimately say 0, and a test certainly does.
154
+ const interval = Number(resp.interval ?? DEFAULT_POLL_INTERVAL_SECONDS);
154
155
  const expiresIn = Number(resp.expires_in || resp.expiresIn || DEFAULT_EXPIRY_SECONDS);
155
156
 
156
157
  if (!userCode || !deviceCode || !verificationUri) {
@@ -162,11 +163,17 @@ async function loginWithDeviceFlow({ apiUrl }) {
162
163
  // without hardcoding a host that is wrong for UAT and wrong for every self-hosted install.
163
164
  rememberAppUrl(verificationUri);
164
165
 
165
- console.log(`\n Your one-time code: ${color.bold(color.cyan(userCode))}`);
166
- console.log(` Open: ${color.cyan(verificationUri)}\n`);
166
+ // Written past any rail prefix: the box draws its own left edge, which is the rail.
167
+ const nested = !!require("../lib/nestedOutput").currentPad();
168
+ console.log("");
169
+ for (const line of codeBox(userCode, expiresIn, nested)) process.stdout.write(`${line}\n`);
170
+ // Two columns in, so these line up with the text inside the box above.
171
+ console.log(`\n Opening ${color.cyan(verificationUri)}...`);
172
+ console.log(color.dim(" If the browser didn't open, go to:"));
173
+ console.log(` ${color.cyan(verificationUri)}\n`);
167
174
  browser.open(verificationUri);
168
175
 
169
- const sp = spinner.start(" Waiting for browser approval...");
176
+ const sp = spinner.start(" Waiting for browser approval...");
170
177
  const deadline = Date.now() + expiresIn * 1000;
171
178
  try {
172
179
  while (Date.now() < deadline) {
@@ -181,7 +188,8 @@ async function loginWithDeviceFlow({ apiUrl }) {
181
188
  if (token) {
182
189
  const email = (tokenResp.user && tokenResp.user.email) || tokenResp.email || null;
183
190
  const cliToken = tokenResp.niro_api_key || tokenResp.niroApiKey || null;
184
- sp.stop(`${color.green("✓")} Logged in${email ? ` as ${color.bold(email)}` : ""}.`);
191
+ // Inside `niro start` the step's own closing line names the account; say it once.
192
+ sp.stop(nested ? undefined : `${color.green("✓")} Logged in${email ? ` as ${color.bold(email)}` : ""}.`);
185
193
  credentials.save({
186
194
  apiUrl,
187
195
  token,
@@ -190,11 +198,16 @@ async function loginWithDeviceFlow({ apiUrl }) {
190
198
  email,
191
199
  userId: (tokenResp.user && tokenResp.user.id) || tokenResp.user_id || tokenResp.userId || null,
192
200
  });
193
- return;
201
+ return { email };
194
202
  }
195
203
  } catch (err) {
196
204
  if (isPending(err)) continue;
197
- throw err;
205
+ const terminal = TERMINAL_ANSWERS[errorCode(err)];
206
+ if (!terminal) throw err;
207
+ sp.stop();
208
+ for (const line of terminal.say) console.log(` ${line}`);
209
+ process.exitCode = terminal.exitCode;
210
+ return terminal.result;
198
211
  }
199
212
  }
200
213
  sp.stop(`${color.red("✗")} Login timed out. Run ${color.cyan("niro login")} to try again.`);
@@ -204,6 +217,38 @@ async function loginWithDeviceFlow({ apiUrl }) {
204
217
  }
205
218
  }
206
219
 
220
+ /**
221
+ * The device code in a titled box, so it stands out from the lines around it:
222
+ *
223
+ * Approve in your browser ─────┐
224
+ * │
225
+ * Your code: │
226
+ * ABCD-EFGH │
227
+ * │
228
+ * Code expires in 15 minutes │
229
+ * ───────────────────────────────┘
230
+ */
231
+ function codeBox(userCode, expiresIn, nested = false) {
232
+ const minutes = Math.max(1, Math.round(expiresIn / 60));
233
+ const expiry = `Code expires in ${minutes} minute${minutes === 1 ? "" : "s"}`;
234
+ const rows = [
235
+ ["", ""],
236
+ [color.dim(color.bold("Your authorization code:")), "Your authorization code:"],
237
+ ["", ""],
238
+ [` ${color.bold(color.green(userCode))}`, ` ${userCode}`],
239
+ ["", ""],
240
+ [color.dim(expiry), expiry],
241
+ ["", ""],
242
+ ];
243
+ const title = "Browser Authorization";
244
+ const width = Math.max(title.length + 4, ...rows.map(([, plain]) => plain.length + 2));
245
+ // The box's left edge is the setup rail itself: title on the marker row, bottom joins the rail.
246
+ const out = [`${color.green("◇")} ${title} ${color.dim(`${"─".repeat(width - title.length - 1)}┐`)}`];
247
+ for (const [shown, plain] of rows) out.push(`│ ${shown}${" ".repeat(width - plain.length)}${color.dim("│")}`);
248
+ out.push(`${nested ? "├" : "└"}${color.dim(`${"─".repeat(width + 2)}┘`)}`);
249
+ return out;
250
+ }
251
+
207
252
  /**
208
253
  * Store the web app's origin, taken from the device-flow verification URI.
209
254
  *
@@ -219,16 +264,48 @@ function rememberAppUrl(verificationUri) {
219
264
  }
220
265
  }
221
266
 
222
- function isPending(err) {
223
- const body = err.body;
224
- if (!body) return false;
267
+ /** The error code the device-token endpoint answered with, or null. */
268
+ function errorCode(err) {
269
+ const body = err && err.body;
270
+ if (!body) return null;
225
271
  const code = typeof body === "string" ? body : body.error || body.message;
226
- if (typeof code !== "string") return false;
272
+ return typeof code === "string" ? code.trim() : null;
273
+ }
274
+
275
+ function isPending(err) {
276
+ const code = errorCode(err);
277
+ if (!code) return false;
227
278
  return /authorization[_-]?pending/i.test(code) || /slow[_-]?down/i.test(code);
228
279
  }
229
280
 
281
+ /**
282
+ * Answers that end the wait (contract C1 + addendum). Each one is a sentence for the person
283
+ * at the terminal, not a stack: a waiting account used to sit on the spinner until the code
284
+ * expired, 30 minutes later, because only pending and slow_down were understood.
285
+ */
286
+ const RESTART = ["The sign-in was not completed. Run niro start again to get a new code."];
287
+ const TERMINAL_ANSWERS = {
288
+ account_waiting: {
289
+ say: ["You are on the list. We will email you when you are in. Then run niro start again."],
290
+ exitCode: 0,
291
+ result: { waiting: true },
292
+ },
293
+ account_blocked: {
294
+ say: ["This account cannot sign in to Niro. If you think that is a mistake, reply to any Niro email."],
295
+ exitCode: 1,
296
+ result: { blocked: true },
297
+ },
298
+ request_submitted: {
299
+ say: ["Request sent. We reply within one business day. Run niro start again once your trial starts."],
300
+ exitCode: 0,
301
+ result: { requestSubmitted: true },
302
+ },
303
+ access_denied: { say: RESTART, exitCode: 1, result: { denied: true } },
304
+ expired_token: { say: RESTART, exitCode: 1, result: { expired: true } },
305
+ };
306
+
230
307
  function sleep(ms) {
231
308
  return new Promise((resolve) => setTimeout(resolve, ms));
232
309
  }
233
310
 
234
- module.exports = { login, rememberAppUrl };
311
+ module.exports = { login, rememberAppUrl, codeBox };
@@ -18,18 +18,34 @@ const childProcess = require("child_process");
18
18
  * `niro upgrade` passes its own `--client <id>`, because the flags on its command line
19
19
  * (`--finish`, `--from`) are not the wizard's.
20
20
  */
21
- function runWizard(scriptName, label, args) {
21
+ function runWizard(scriptName, label, args, { quiet = false } = {}) {
22
22
  const wizardArgs = Array.isArray(args) ? args : process.argv.slice(4);
23
23
  return new Promise((resolve, reject) => {
24
24
  const child = childProcess.spawn(
25
25
  process.execPath,
26
26
  [path.join(__dirname, "..", "mcp", scriptName), ...wizardArgs],
27
- { stdio: "inherit" }
27
+ // The child prints for itself, out of reach of withPrefix; it is told the prefix instead,
28
+ // so inside `niro start` its lines stay on the setup rail. `quiet` keeps its lines for a
29
+ // failure instead (`niro uninstall` says one line per step).
30
+ {
31
+ stdio: quiet ? ["inherit", "pipe", "pipe"] : "inherit",
32
+ env: { ...process.env, NIRO_OUTPUT_PREFIX: require("../lib/nestedOutput").currentPad() },
33
+ }
28
34
  );
35
+ let said = "";
36
+ if (quiet) {
37
+ child.stdout.on("data", (c) => (said += c));
38
+ child.stderr.on("data", (c) => (said += c));
39
+ }
29
40
  child.on("error", reject);
30
41
  child.on("exit", (code) => {
31
- if (code === 0) resolve();
32
- else reject(new Error(`MCP ${label} wizard exited with code ${code}`));
42
+ if (code === 0) return resolve(said);
43
+ const err = new Error(`MCP ${label} wizard exited with code ${code}${said.trim() ? `\n${said.trim()}` : ""}`);
44
+ // A wizard that printed to the terminal has already said why it stopped; the CLI exits
45
+ // with its code and adds nothing ("Error: MCP setup wizard exited with code 1" read as a crash).
46
+ err.alreadySaid = !quiet;
47
+ err.exitCode = code;
48
+ reject(err);
33
49
  });
34
50
  });
35
51
  }
@@ -38,8 +54,8 @@ async function install(args) {
38
54
  return runWizard("setup.js", "setup", args);
39
55
  }
40
56
 
41
- async function uninstall() {
42
- return runWizard("teardown.js", "uninstall");
57
+ async function uninstall(args, opts) {
58
+ return runWizard("teardown.js", "uninstall", args, opts);
43
59
  }
44
60
 
45
61
  function serve() {