@niroai/niro 0.3.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 (43) hide show
  1. package/README.md +291 -0
  2. package/bin/niro.js +2 -0
  3. package/package.json +41 -0
  4. package/src/commands/_resolveProject.js +142 -0
  5. package/src/commands/add-project.js +190 -0
  6. package/src/commands/add-repo.js +270 -0
  7. package/src/commands/build.js +118 -0
  8. package/src/commands/delete.js +66 -0
  9. package/src/commands/edit.js +601 -0
  10. package/src/commands/info.js +172 -0
  11. package/src/commands/init.js +1166 -0
  12. package/src/commands/login.js +214 -0
  13. package/src/commands/logout.js +33 -0
  14. package/src/commands/mcp.js +40 -0
  15. package/src/commands/projects.js +76 -0
  16. package/src/commands/release.js +175 -0
  17. package/src/commands/remove.js +95 -0
  18. package/src/commands/status.js +75 -0
  19. package/src/commands/takeover.js +204 -0
  20. package/src/commands/watch.js +498 -0
  21. package/src/commands/whoami.js +74 -0
  22. package/src/index.js +293 -0
  23. package/src/lib/agentApi.js +276 -0
  24. package/src/lib/api.js +230 -0
  25. package/src/lib/browser.js +30 -0
  26. package/src/lib/color.js +46 -0
  27. package/src/lib/config.js +38 -0
  28. package/src/lib/credentials.js +73 -0
  29. package/src/lib/folderSync.js +503 -0
  30. package/src/lib/git.js +190 -0
  31. package/src/lib/moduleExcludeSuggestions.js +57 -0
  32. package/src/lib/niroProjectFile.js +76 -0
  33. package/src/lib/pendingRequests.js +99 -0
  34. package/src/lib/prompt.js +118 -0
  35. package/src/lib/resolveContext.js +108 -0
  36. package/src/lib/secret.js +114 -0
  37. package/src/lib/service.js +221 -0
  38. package/src/lib/spinner.js +109 -0
  39. package/src/lib/watchDaemon.js +93 -0
  40. package/src/lib/watchState.js +217 -0
  41. package/src/mcp/server.js +764 -0
  42. package/src/mcp/setup.js +1976 -0
  43. package/src/mcp/teardown.js +673 -0
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Interactive teardown for the Niro MCP Server — the exact inverse of setup.js.
5
+ *
6
+ * `niro mcp uninstall` removes everything `niro mcp install` wrote, per editor:
7
+ * - the MCP server registration (claude/codex CLI, or the editor's JSON config)
8
+ * - the niro hooks in ~/.claude/settings.json (PostCompact + UserPromptSubmit + PreToolUse)
9
+ * - the niro section in markdown config (CLAUDE.md / AGENTS.md / Windsurf rules)
10
+ * - the generated skills (~/.claude/skills, the shared ~/.agents/skills)
11
+ * - Cursor's "niro:*" allowlist entry and project rule
12
+ * - (with --all) the mcpServerUrl fallback in ~/.niro/config.json
13
+ *
14
+ * It deliberately LEAVES ~/.niro/credentials.json (your `niro login` session) alone —
15
+ * use `niro logout` for that — and never deletes a user's own CLAUDE.md/AGENTS.md, only
16
+ * strips the niro-marked section out of them.
17
+ *
18
+ * Usage:
19
+ * niro mcp uninstall # interactive: pick a client (or "all")
20
+ * niro mcp uninstall --client codex
21
+ * niro mcp uninstall --all [-y]
22
+ *
23
+ * The string-surgery helpers (removeMarkerSection, filterNiroHooks, removeMcpServerEntry,
24
+ * removeNiroFromCursorAllowlist) are pure and unit-tested in test/teardownConfig.test.js.
25
+ * The niro-hook matchers and the section marker are imported from setup.js so install and
26
+ * uninstall can never disagree on what counts as "niro's".
27
+ */
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+ const readline = require("readline");
32
+ const { execSync } = require("child_process");
33
+ const { formatPrompt } = require("../lib/prompt");
34
+ const {
35
+ CLIENTS,
36
+ NIRO_MD_MARKER,
37
+ NIRO_MD_SECTIONS,
38
+ isNiroPostCompactHook,
39
+ isNiroUserPromptHook,
40
+ isNiroPreToolUseHook,
41
+ userHome,
42
+ } = require("./setup");
43
+
44
+ // ─── pure helpers (unit-tested — no fs, no $HOME) ────────────────────────────
45
+
46
+ /**
47
+ * Remove the niro section from markdown `content`. install appends one of `knownSections`
48
+ * (`\n<marker>\n## Niro MCP\n…body…\n`) — almost always at EOF.
49
+ *
50
+ * Primary path: delete the EXACT section text install wrote. Because we match the whole known
51
+ * block (not "marker → next heading / EOF"), a user's own notes added BELOW the block survive,
52
+ * the file is restored byte-for-byte, duplicate blocks are all removed, and a differently-worded
53
+ * example of the marker elsewhere is untouched. Both LF and CRLF copies are tried.
54
+ *
55
+ * Fallback (the section text drifted — older install version or a hand-edit): a BOUNDED removal
56
+ * that strips the marker line and the "## Niro MCP" block it heads but stops at the first blank
57
+ * line / next heading / EOF, and refuses to touch a marker sitting inside a ``` code fence — so
58
+ * trailing user content is never swallowed. Returns { content, removed }.
59
+ */
60
+ function removeMarkerSection(content, marker, knownSections = NIRO_MD_SECTIONS) {
61
+ if (typeof content !== "string" || !content.includes(marker)) {
62
+ return { content, removed: false };
63
+ }
64
+
65
+ let next = content;
66
+ let removed = false;
67
+
68
+ // Primary: exact removal of every known section (LF, then a CRLF copy).
69
+ for (const section of knownSections || []) {
70
+ for (const variant of [section, section.replace(/\n/g, "\r\n")]) {
71
+ while (next.includes(variant)) {
72
+ next = next.replace(variant, "");
73
+ removed = true;
74
+ }
75
+ }
76
+ }
77
+
78
+ // Fallback: any marker that survived exact removal. Loop so multiple drifted blocks are all
79
+ // handled; stop when no marker remains or a pass makes no progress (e.g. an in-fence marker
80
+ // we deliberately leave alone), which also prevents an infinite loop.
81
+ while (next.includes(marker)) {
82
+ const r = removeMarkerSectionBounded(next, marker);
83
+ if (!r.removed || r.content === next) break;
84
+ next = r.content;
85
+ removed = true;
86
+ }
87
+
88
+ return { content: next, removed };
89
+ }
90
+
91
+ /**
92
+ * Bounded fallback for removeMarkerSection: remove the marker line and the "## Niro MCP" block
93
+ * it heads, stopping at the first blank line, the next heading, or EOF. A marker inside a code
94
+ * fence is treated as documentation and left untouched. Operates line-wise so CRLF survives.
95
+ */
96
+ function removeMarkerSectionBounded(content, marker) {
97
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
98
+ const lines = content.split(/\r?\n/);
99
+ const markerLine = lines.findIndex((l) => l.includes(marker));
100
+ if (markerLine === -1) return { content, removed: false };
101
+
102
+ // If the marker sits inside a ``` fenced block, it's an example, not the installed section.
103
+ let fences = 0;
104
+ for (let i = 0; i < markerLine; i++) {
105
+ if (lines[i].trimStart().startsWith("```")) fences++;
106
+ }
107
+ if (fences % 2 === 1) return { content, removed: false };
108
+
109
+ let end = markerLine + 1; // exclusive index of the first line to KEEP
110
+ if (end < lines.length && lines[end].startsWith("## ")) {
111
+ end++; // the "## Niro MCP" heading
112
+ while (end < lines.length && lines[end].trim() !== "" && !lines[end].startsWith("#")) {
113
+ end++; // contiguous niro body lines — stop at a blank line or the next heading
114
+ }
115
+ }
116
+
117
+ let begin = markerLine;
118
+ if (begin > 0 && lines[begin - 1].trim() === "") begin--; // drop install's separator blank line
119
+
120
+ lines.splice(begin, end - begin);
121
+ return { content: lines.join(eol), removed: true };
122
+ }
123
+
124
+ /**
125
+ * Strip niro's PostCompact and UserPromptSubmit hook entries from a parsed Claude Code
126
+ * settings object (mutates and returns it). Uses the same matchers install uses. Empty
127
+ * arrays / an empty `hooks` object are pruned so the file stays tidy. Other hooks are
128
+ * untouched. Returns { settings, removed } where `removed` is the count deleted.
129
+ */
130
+ function filterNiroHooks(settings) {
131
+ let removed = 0;
132
+ if (!settings || typeof settings !== "object" || !settings.hooks || typeof settings.hooks !== "object") {
133
+ return { settings, removed };
134
+ }
135
+ const hooks = settings.hooks;
136
+
137
+ if (Array.isArray(hooks.PostCompact)) {
138
+ const before = hooks.PostCompact.length;
139
+ hooks.PostCompact = hooks.PostCompact.filter((h) => !isNiroPostCompactHook(h));
140
+ removed += before - hooks.PostCompact.length;
141
+ if (hooks.PostCompact.length === 0) delete hooks.PostCompact;
142
+ }
143
+ if (Array.isArray(hooks.UserPromptSubmit)) {
144
+ const before = hooks.UserPromptSubmit.length;
145
+ hooks.UserPromptSubmit = hooks.UserPromptSubmit.filter((h) => !isNiroUserPromptHook(h));
146
+ removed += before - hooks.UserPromptSubmit.length;
147
+ if (hooks.UserPromptSubmit.length === 0) delete hooks.UserPromptSubmit;
148
+ }
149
+ if (Array.isArray(hooks.PreToolUse)) {
150
+ const before = hooks.PreToolUse.length;
151
+ hooks.PreToolUse = hooks.PreToolUse.filter((h) => !isNiroPreToolUseHook(h));
152
+ removed += before - hooks.PreToolUse.length;
153
+ if (hooks.PreToolUse.length === 0) delete hooks.PreToolUse;
154
+ }
155
+ if (Object.keys(hooks).length === 0) delete settings.hooks;
156
+
157
+ return { settings, removed };
158
+ }
159
+
160
+ /**
161
+ * Delete the `<topLevelKey>.niro` entry from a parsed editor MCP config (mutates and returns
162
+ * it). Other MCP servers are kept; an emptied top-level object is pruned. `topLevelKey`
163
+ * defaults to "mcpServers" (most clients); OpenCode uses "mcp", VS Code uses "servers" — see
164
+ * setup.js's CLIENTS[...].topLevelKey. Returns { config, removed }.
165
+ */
166
+ function removeMcpServerEntry(config, topLevelKey = "mcpServers") {
167
+ if (!config || typeof config !== "object" || !config[topLevelKey] || typeof config[topLevelKey] !== "object") {
168
+ return { config, removed: false };
169
+ }
170
+ if (!Object.prototype.hasOwnProperty.call(config[topLevelKey], "niro")) {
171
+ return { config, removed: false };
172
+ }
173
+ delete config[topLevelKey].niro;
174
+ if (Object.keys(config[topLevelKey]).length === 0) delete config[topLevelKey];
175
+ return { config, removed: true };
176
+ }
177
+
178
+ /**
179
+ * Remove the exact "niro:*" pattern install appended to Cursor's mcpAllowlist (mutates and
180
+ * returns the parsed permissions object). A broader user pattern like "*:*" is left alone —
181
+ * install never wrote that, so we never remove it. Returns { data, removed }.
182
+ */
183
+ function removeNiroFromCursorAllowlist(data) {
184
+ if (!data || typeof data !== "object" || !Array.isArray(data.mcpAllowlist)) {
185
+ return { data, removed: false };
186
+ }
187
+ const before = data.mcpAllowlist.length;
188
+ data.mcpAllowlist = data.mcpAllowlist.filter(
189
+ (e) => !(typeof e === "string" && e.trim().toLowerCase() === "niro:*"),
190
+ );
191
+ return { data, removed: data.mcpAllowlist.length < before };
192
+ }
193
+
194
+ // ─── filesystem wrappers (resolve $HOME, log) ────────────────────────────────
195
+
196
+ function deleteFileIfPresent(absPath, label) {
197
+ try {
198
+ if (!fs.existsSync(absPath)) return false;
199
+ fs.rmSync(absPath, { force: true });
200
+ console.log(` Removed ${label}: ${absPath}`);
201
+ // Clean up a now-empty generated skill dir (…/skills/niro-skill/).
202
+ try {
203
+ const parent = path.dirname(absPath);
204
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent);
205
+ } catch {
206
+ /* leave the parent if it isn't empty / can't be removed */
207
+ }
208
+ return true;
209
+ } catch (e) {
210
+ console.warn(` Could not remove ${label} (${absPath}): ${e.message}`);
211
+ return false;
212
+ }
213
+ }
214
+
215
+ function stripMarkerFromFile(absPath, label) {
216
+ if (!fs.existsSync(absPath)) return false;
217
+ let content;
218
+ try {
219
+ content = fs.readFileSync(absPath, "utf-8");
220
+ } catch (e) {
221
+ console.warn(` Could not read ${absPath}: ${e.message}`);
222
+ return false;
223
+ }
224
+ const { content: next, removed } = removeMarkerSection(content, NIRO_MD_MARKER);
225
+ if (!removed) return false;
226
+ try {
227
+ fs.writeFileSync(absPath, next);
228
+ console.log(` Removed niro section from ${label}: ${absPath}`);
229
+ return true;
230
+ } catch (e) {
231
+ console.warn(` Could not write ${absPath}: ${e.message}`);
232
+ return false;
233
+ }
234
+ }
235
+
236
+ function stripNiroHooksFromSettings(absPath) {
237
+ if (!fs.existsSync(absPath)) return false;
238
+ let settings;
239
+ try {
240
+ settings = JSON.parse(fs.readFileSync(absPath, "utf-8"));
241
+ } catch (e) {
242
+ console.warn(` Could not parse ${absPath}: ${e.message} — leaving it untouched.`);
243
+ return false;
244
+ }
245
+ const { removed } = filterNiroHooks(settings);
246
+ if (!removed) return false;
247
+ try {
248
+ fs.writeFileSync(absPath, JSON.stringify(settings, null, 2) + "\n");
249
+ console.log(` Removed ${removed} niro hook(s) from ${absPath}`);
250
+ return true;
251
+ } catch (e) {
252
+ console.warn(` Could not write ${absPath}: ${e.message}`);
253
+ return false;
254
+ }
255
+ }
256
+
257
+ function removeNiroFromJsonConfig(absPath, label, topLevelKey = "mcpServers") {
258
+ if (!fs.existsSync(absPath)) {
259
+ console.log(` ${label} MCP config not found — skipping (${absPath}).`);
260
+ return false;
261
+ }
262
+ let config;
263
+ try {
264
+ config = JSON.parse(fs.readFileSync(absPath, "utf-8"));
265
+ } catch (e) {
266
+ console.warn(` Could not parse ${absPath}: ${e.message} — leaving it untouched.`);
267
+ return false;
268
+ }
269
+ const { removed } = removeMcpServerEntry(config, topLevelKey);
270
+ if (!removed) {
271
+ console.log(` No niro MCP entry in ${label} config — skipping.`);
272
+ return false;
273
+ }
274
+ try {
275
+ fs.writeFileSync(absPath, JSON.stringify(config, null, 2) + "\n");
276
+ console.log(` Removed niro MCP entry from ${label}: ${absPath}`);
277
+ return true;
278
+ } catch (e) {
279
+ console.warn(` Could not write ${absPath}: ${e.message}`);
280
+ return false;
281
+ }
282
+ }
283
+
284
+ function removeCursorPermissions(absPath) {
285
+ if (!fs.existsSync(absPath)) return false;
286
+ let data;
287
+ try {
288
+ data = JSON.parse(fs.readFileSync(absPath, "utf-8"));
289
+ } catch (e) {
290
+ console.warn(` Could not parse ${absPath}: ${e.message} — leaving it untouched.`);
291
+ return false;
292
+ }
293
+ const { removed } = removeNiroFromCursorAllowlist(data);
294
+ if (!removed) return false;
295
+ try {
296
+ fs.writeFileSync(absPath, JSON.stringify(data, null, 2) + "\n");
297
+ console.log(` Removed "niro:*" from Cursor permissions: ${absPath}`);
298
+ return true;
299
+ } catch (e) {
300
+ console.warn(` Could not write ${absPath}: ${e.message}`);
301
+ return false;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Best-effort MCP de-registration via an editor CLI. A missing CLI or an already-removed
307
+ * server are both fine (install tolerates the inverse), so failure is logged softly, never
308
+ * thrown.
309
+ */
310
+ function runMcpRemove(cmd, args) {
311
+ try {
312
+ execSync([cmd, ...args].join(" "), { stdio: "inherit" });
313
+ return true;
314
+ } catch {
315
+ console.log(` (${cmd} unavailable or niro already unregistered — skipping.)`);
316
+ return false;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Is the niro MCP server still registered for `clientId`? Used to protect the shared
322
+ * ~/.agents/skills directory (written by BOTH Codex and Windsurf): we only delete it when
323
+ * the OTHER cross-agent editor is gone, so uninstalling one never breaks the other.
324
+ */
325
+ function isClientConfigured(clientId) {
326
+ try {
327
+ if (clientId === "codex") {
328
+ const cfg = path.join(userHome(), ".codex", "config.toml");
329
+ return fs.existsSync(cfg) && fs.readFileSync(cfg, "utf-8").includes("[mcp_servers.niro]");
330
+ }
331
+ if (clientId === "windsurf") {
332
+ const cfg = CLIENTS.windsurf.configPath();
333
+ if (!fs.existsSync(cfg)) return false;
334
+ const data = JSON.parse(fs.readFileSync(cfg, "utf-8"));
335
+ return !!(data && data.mcpServers && data.mcpServers.niro);
336
+ }
337
+ } catch {
338
+ return false;
339
+ }
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Remove the shared cross-agent skills (~/.agents/skills) on behalf of `forClientId`,
345
+ * but only if the other cross-agent editor (codex ↔ windsurf) is no longer configured.
346
+ * Call AFTER that client's own registration has been removed, so the check is accurate.
347
+ */
348
+ function removeSharedAgentsSkills(forClientId) {
349
+ const other = forClientId === "codex" ? "windsurf" : "codex";
350
+ if (isClientConfigured(other)) {
351
+ console.log(` Keeping shared ~/.agents/skills — still used by ${other} (use --all to remove everything).`);
352
+ return;
353
+ }
354
+ forceRemoveAgentsSkills();
355
+ }
356
+
357
+ /**
358
+ * Delete the shared cross-agent skills unconditionally. Used by the full (--all) uninstall,
359
+ * where every editor is being torn down, so the "is the other one still configured?" guard
360
+ * in removeSharedAgentsSkills would (a) never fire correctly mid-sweep — the other client
361
+ * isn't processed yet — and (b) be pointless since both are going away.
362
+ */
363
+ function forceRemoveAgentsSkills() {
364
+ const home = userHome();
365
+ deleteFileIfPresent(path.join(home, ".agents", "skills", "niro-skill", "SKILL.md"), "shared niro skill");
366
+ deleteFileIfPresent(path.join(home, ".agents", "skills", "niro-cli", "SKILL.md"), "shared niro-cli skill");
367
+ }
368
+
369
+ // ─── per-client uninstallers (mirror setup.js's per-client installers) ────────
370
+
371
+ function uninstallClaudeCode() {
372
+ console.log("\n Removing Niro from Claude Code...\n");
373
+ const home = userHome();
374
+ runMcpRemove("claude", ["mcp", "remove", "niro", "--scope", "user"]);
375
+ stripNiroHooksFromSettings(path.join(home, ".claude", "settings.json"));
376
+ stripMarkerFromFile(path.join(home, ".claude", "CLAUDE.md"), "Claude Code memory");
377
+ deleteFileIfPresent(path.join(home, ".claude", "skills", "niro-skill", "SKILL.md"), "niro skill");
378
+ deleteFileIfPresent(path.join(home, ".claude", "skills", "niro-cli", "SKILL.md"), "niro-cli skill");
379
+ }
380
+
381
+ function uninstallCodex(opts = {}) {
382
+ console.log("\n Removing Niro from Codex...\n");
383
+ const home = userHome();
384
+ // codex mcp remove drops the whole [mcp_servers.niro] table, incl. the approval-mode line.
385
+ runMcpRemove("codex", ["mcp", "remove", "niro"]);
386
+ stripMarkerFromFile(path.join(home, ".codex", "AGENTS.md"), "Codex memory");
387
+ // On a full sweep the shared skills are removed centrally (forceRemoveAgentsSkills).
388
+ if (!opts.full) removeSharedAgentsSkills("codex");
389
+ }
390
+
391
+ function uninstallClaudeDesktop() {
392
+ console.log("\n Removing Niro from Claude Desktop...\n");
393
+ removeNiroFromJsonConfig(CLIENTS["claude-desktop"].configPath(), "Claude Desktop");
394
+ }
395
+
396
+ function uninstallCursor() {
397
+ console.log("\n Removing Niro from Cursor...\n");
398
+ const home = userHome();
399
+ removeNiroFromJsonConfig(CLIENTS.cursor.configPath(), "Cursor");
400
+ removeCursorPermissions(path.join(home, ".cursor", "permissions.json"));
401
+ // The Cursor rule is project-scoped — it lives in the repo the install was run from.
402
+ deleteFileIfPresent(path.join(process.cwd(), ".cursor", "rules", "niro.mdc"), "Cursor rule");
403
+ }
404
+
405
+ function uninstallWindsurf(opts = {}) {
406
+ console.log("\n Removing Niro from Windsurf...\n");
407
+ const home = userHome();
408
+ removeNiroFromJsonConfig(CLIENTS.windsurf.configPath(), "Windsurf");
409
+ stripMarkerFromFile(path.join(home, ".codeium", "windsurf", "memories", "global_rules.md"), "Windsurf global rules");
410
+ // On a full sweep the shared skills are removed centrally (forceRemoveAgentsSkills).
411
+ if (!opts.full) removeSharedAgentsSkills("windsurf");
412
+ }
413
+
414
+ function uninstallVsCode() {
415
+ console.log("\n Removing Niro from VS Code...\n");
416
+ removeNiroFromJsonConfig(CLIENTS.vscode.configPath(), "VS Code", "servers");
417
+ }
418
+
419
+ function uninstallGeminiCli() {
420
+ console.log("\n Removing Niro from Gemini CLI...\n");
421
+ runMcpRemove("gemini", ["mcp", "remove", "-s", "user", "niro"]);
422
+ stripMarkerFromFile(path.join(userHome(), ".gemini", "GEMINI.md"), "Gemini CLI memory");
423
+ }
424
+
425
+ function uninstallCopilotCli() {
426
+ console.log("\n Removing Niro from GitHub Copilot CLI...\n");
427
+ runMcpRemove("copilot", ["mcp", "remove", "niro"]);
428
+ stripMarkerFromFile(path.join(userHome(), ".copilot", "copilot-instructions.md"), "Copilot CLI instructions");
429
+ }
430
+
431
+ function uninstallFactory() {
432
+ console.log("\n Removing Niro from Factory (droid)...\n");
433
+ runMcpRemove("droid", ["mcp", "remove", "niro"]);
434
+ stripMarkerFromFile(path.join(userHome(), ".factory", "AGENTS.md"), "Factory AGENTS.md");
435
+ }
436
+
437
+ function uninstallGoose() {
438
+ console.log("\n Removing Niro from Goose...\n");
439
+ const { gooseConfigPath } = require("./setup");
440
+ const configPath = gooseConfigPath();
441
+ if (!fs.existsSync(configPath)) {
442
+ console.log(` Goose config not found — skipping (${configPath}).`);
443
+ return;
444
+ }
445
+ let content;
446
+ try {
447
+ content = fs.readFileSync(configPath, "utf-8");
448
+ } catch (e) {
449
+ console.warn(` Could not read ${configPath}: ${e.message} — leaving it untouched.`);
450
+ return;
451
+ }
452
+ // Mirrors the bounded block install wrote: the " niro:" entry and everything indented
453
+ // under it, stopping at the next line back at 2-space (or shallower) indent.
454
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
455
+ const lines = content.split(/\r?\n/);
456
+ const start = lines.findIndex((l) => /^ {2}niro:\s*$/.test(l));
457
+ if (start === -1) {
458
+ console.log(" No `niro` extension entry in Goose config — skipping.");
459
+ return;
460
+ }
461
+ let end = start + 1;
462
+ while (end < lines.length && /^( {4,}|\s*$)/.test(lines[end]) && !/^\S/.test(lines[end])) end++;
463
+ lines.splice(start, end - start);
464
+ try {
465
+ fs.writeFileSync(configPath, lines.join(eol));
466
+ console.log(` Removed niro extension from Goose config: ${configPath}`);
467
+ } catch (e) {
468
+ console.warn(` Could not write ${configPath}: ${e.message}`);
469
+ }
470
+ }
471
+
472
+ function uninstallOpenCode() {
473
+ console.log("\n Removing Niro from OpenCode...\n");
474
+ removeNiroFromJsonConfig(CLIENTS.opencode.configPath(), "OpenCode", "mcp");
475
+ stripMarkerFromFile(path.join(userHome(), ".config", "opencode", "AGENTS.md"), "OpenCode AGENTS.md");
476
+ }
477
+
478
+ function uninstallCline() {
479
+ console.log("\n Removing Niro from Cline...\n");
480
+ removeNiroFromJsonConfig(CLIENTS.cline.configPath(), "Cline");
481
+ deleteFileIfPresent(path.join(userHome(), "Documents", "Cline", "Rules", "niro.md"), "Cline rule");
482
+ }
483
+
484
+ function uninstallKiro() {
485
+ console.log("\n Removing Niro from Kiro...\n");
486
+ removeNiroFromJsonConfig(CLIENTS.kiro.configPath(), "Kiro");
487
+ deleteFileIfPresent(path.join(userHome(), ".kiro", "steering", "niro.md"), "Kiro steering file");
488
+ }
489
+
490
+ /**
491
+ * Antigravity's configPath() picks legacy-vs-unified based on which files exist RIGHT NOW —
492
+ * that can differ between install time and uninstall time (e.g. the user upgrades Antigravity
493
+ * in between, creating the unified file while the legacy one still has the niro entry).
494
+ * Rather than trust configPath()'s single derived answer here, check both candidate paths and
495
+ * remove the niro entry from whichever one(s) actually have it, so an install written to
496
+ * either file is always found.
497
+ */
498
+ function uninstallAntigravity() {
499
+ console.log("\n Removing Niro from Antigravity...\n");
500
+ const home = userHome();
501
+ const legacy = path.join(home, ".gemini", "antigravity", "mcp_config.json");
502
+ const unified = path.join(home, ".gemini", "config", "mcp_config.json");
503
+ for (const p of [legacy, unified]) {
504
+ removeNiroFromJsonConfig(p, "Antigravity");
505
+ }
506
+ }
507
+
508
+ const UNINSTALLERS = {
509
+ "claude-code": uninstallClaudeCode,
510
+ codex: uninstallCodex,
511
+ "claude-desktop": uninstallClaudeDesktop,
512
+ cursor: uninstallCursor,
513
+ windsurf: uninstallWindsurf,
514
+ vscode: uninstallVsCode,
515
+ "gemini-cli": uninstallGeminiCli,
516
+ "copilot-cli": uninstallCopilotCli,
517
+ factory: uninstallFactory,
518
+ goose: uninstallGoose,
519
+ opencode: uninstallOpenCode,
520
+ cline: uninstallCline,
521
+ kiro: uninstallKiro,
522
+ antigravity: uninstallAntigravity,
523
+ };
524
+
525
+ /**
526
+ * Drop the mcpServerUrl fallback install saved to ~/.niro/config.json. Only invoked for a
527
+ * full (--all) uninstall — it's a global, not per-client, value. apiUrl and any other keys
528
+ * are preserved; credentials.json is never touched here.
529
+ */
530
+ function removeGlobalMcpServerUrl() {
531
+ let cfg;
532
+ try {
533
+ cfg = require("../lib/config");
534
+ } catch {
535
+ return;
536
+ }
537
+ try {
538
+ const current = cfg.load();
539
+ if (!Object.prototype.hasOwnProperty.call(current, "mcpServerUrl")) return;
540
+ delete current.mcpServerUrl;
541
+ fs.writeFileSync(cfg.FILE, JSON.stringify(current, null, 2) + "\n");
542
+ console.log(` Removed mcpServerUrl from ${cfg.FILE}`);
543
+ } catch (e) {
544
+ console.warn(` Could not update ~/.niro/config.json: ${e.message}`);
545
+ }
546
+ }
547
+
548
+ // ─── interactive entry ───────────────────────────────────────────────────────
549
+
550
+ function printHeader() {
551
+ console.log("");
552
+ console.log(" Niro MCP Server Uninstall");
553
+ console.log(" -------------------------");
554
+ console.log("");
555
+ }
556
+
557
+ function printClients() {
558
+ console.log(" Clients:");
559
+ Object.keys(UNINSTALLERS).forEach((id) => {
560
+ const c = CLIENTS[id];
561
+ console.log(` ${id.padEnd(18)} ${c ? c.name : id}`);
562
+ });
563
+ console.log(` ${"all".padEnd(18)} Remove from ALL of the above`);
564
+ console.log("");
565
+ }
566
+
567
+ /** Minimal prompt; resolves to the default on empty input or stdin EOF. */
568
+ function ask(rl, question, defaultValue) {
569
+ const prompt = formatPrompt(question, defaultValue);
570
+ return new Promise((resolve) => {
571
+ let done = false;
572
+ const finish = (answer) => {
573
+ if (done) return;
574
+ done = true;
575
+ rl.removeListener("close", onClose);
576
+ resolve((answer || "").trim() || defaultValue || "");
577
+ };
578
+ const onClose = () => finish("");
579
+ rl.once("close", onClose);
580
+ rl.question(prompt, (answer) => finish(answer));
581
+ });
582
+ }
583
+
584
+ function runUninstall(targets, isFull) {
585
+ for (const id of targets) UNINSTALLERS[id]({ full: isFull });
586
+ if (isFull) {
587
+ // Every editor is gone — remove the shared cross-agent skills and the global URL.
588
+ forceRemoveAgentsSkills();
589
+ removeGlobalMcpServerUrl();
590
+ }
591
+
592
+ console.log("\n Uninstall complete. Restart your AI assistant for the change to take effect.\n");
593
+ console.log(" Your `niro login` session is kept — run `niro logout` to clear it.");
594
+ console.log(" The `niro` command itself is still installed. To remove it too:");
595
+ console.log(" npm rm -g niro-mcp-and-cli\n");
596
+ }
597
+
598
+ async function main() {
599
+ const args = process.argv.slice(2);
600
+ let clientArg = null;
601
+ let all = false;
602
+ let assumeYes = false;
603
+ for (let i = 0; i < args.length; i++) {
604
+ if (args[i] === "--client" && args[i + 1]) clientArg = args[++i];
605
+ else if (args[i] === "--all") all = true;
606
+ else if (args[i] === "-y" || args[i] === "--yes") assumeYes = true;
607
+ }
608
+
609
+ printHeader();
610
+
611
+ const allIds = Object.keys(UNINSTALLERS);
612
+ let targets;
613
+
614
+ if (all || clientArg === "all") {
615
+ targets = allIds;
616
+ } else if (clientArg) {
617
+ if (!UNINSTALLERS[clientArg]) {
618
+ console.error(` Unknown client: ${clientArg}\n`);
619
+ printClients();
620
+ process.exit(1);
621
+ }
622
+ targets = [clientArg];
623
+ } else if (!process.stdin.isTTY) {
624
+ // Non-interactive with no target: refuse rather than guess.
625
+ console.error(" No client specified. Pass --client <id> or --all.\n");
626
+ printClients();
627
+ process.exit(1);
628
+ } else {
629
+ printClients();
630
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
631
+ const choice = await ask(rl, " Remove Niro from which client?", "claude-code");
632
+ rl.close();
633
+ if (choice === "all") targets = allIds;
634
+ else if (UNINSTALLERS[choice]) targets = [choice];
635
+ else {
636
+ console.error(`\n Unknown client: ${choice}\n`);
637
+ process.exit(1);
638
+ }
639
+ }
640
+
641
+ const isFull = targets.length === allIds.length;
642
+
643
+ if (!assumeYes && process.stdin.isTTY) {
644
+ console.log(`\n This removes Niro MCP config from: ${targets.join(", ")}`);
645
+ console.log(" (Your `niro login` session in ~/.niro/credentials.json is left intact.)\n");
646
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
647
+ const ans = await ask(rl, " Proceed?", "yes");
648
+ rl.close();
649
+ if (!/^y/i.test(ans)) {
650
+ console.log("\n Aborted. Nothing was changed.\n");
651
+ return;
652
+ }
653
+ }
654
+
655
+ runUninstall(targets, isFull);
656
+ }
657
+
658
+ // Run the wizard only when invoked directly (niro mcp uninstall). When required as a
659
+ // module (tests), expose the pure helpers without running anything.
660
+ if (require.main === module) {
661
+ main().catch((err) => {
662
+ console.error(`Uninstall failed: ${err.message}`);
663
+ process.exit(1);
664
+ });
665
+ }
666
+
667
+ module.exports = {
668
+ removeMarkerSection,
669
+ filterNiroHooks,
670
+ removeMcpServerEntry,
671
+ removeNiroFromCursorAllowlist,
672
+ UNINSTALLERS,
673
+ };