@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.
- package/README.md +291 -0
- package/bin/niro.js +2 -0
- package/package.json +41 -0
- package/src/commands/_resolveProject.js +142 -0
- package/src/commands/add-project.js +190 -0
- package/src/commands/add-repo.js +270 -0
- package/src/commands/build.js +118 -0
- package/src/commands/delete.js +66 -0
- package/src/commands/edit.js +601 -0
- package/src/commands/info.js +172 -0
- package/src/commands/init.js +1166 -0
- package/src/commands/login.js +214 -0
- package/src/commands/logout.js +33 -0
- package/src/commands/mcp.js +40 -0
- package/src/commands/projects.js +76 -0
- package/src/commands/release.js +175 -0
- package/src/commands/remove.js +95 -0
- package/src/commands/status.js +75 -0
- package/src/commands/takeover.js +204 -0
- package/src/commands/watch.js +498 -0
- package/src/commands/whoami.js +74 -0
- package/src/index.js +293 -0
- package/src/lib/agentApi.js +276 -0
- package/src/lib/api.js +230 -0
- package/src/lib/browser.js +30 -0
- package/src/lib/color.js +46 -0
- package/src/lib/config.js +38 -0
- package/src/lib/credentials.js +73 -0
- package/src/lib/folderSync.js +503 -0
- package/src/lib/git.js +190 -0
- package/src/lib/moduleExcludeSuggestions.js +57 -0
- package/src/lib/niroProjectFile.js +76 -0
- package/src/lib/pendingRequests.js +99 -0
- package/src/lib/prompt.js +118 -0
- package/src/lib/resolveContext.js +108 -0
- package/src/lib/secret.js +114 -0
- package/src/lib/service.js +221 -0
- package/src/lib/spinner.js +109 -0
- package/src/lib/watchDaemon.js +93 -0
- package/src/lib/watchState.js +217 -0
- package/src/mcp/server.js +764 -0
- package/src/mcp/setup.js +1976 -0
- package/src/mcp/teardown.js +673 -0
package/src/mcp/setup.js
ADDED
|
@@ -0,0 +1,1976 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Interactive setup for Niro MCP Server.
|
|
5
|
+
*
|
|
6
|
+
* Configures the MCP server for Claude Code, Cursor, Windsurf, or other
|
|
7
|
+
* AI coding assistants. The MCP server URL is resolved automatically
|
|
8
|
+
* (--url flag > NIRO_MCP_SERVER_URL env > ~/.niro/config.json > cloud
|
|
9
|
+
* default) and printed — it is never prompted for. Only the API key is
|
|
10
|
+
* asked, and only when there is no `niro login` session.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* niro mcp install
|
|
14
|
+
* niro mcp install --client cursor
|
|
15
|
+
* niro mcp install --url https://aiassistant.my-company.internal
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const readline = require("readline");
|
|
21
|
+
const { execSync } = require("child_process");
|
|
22
|
+
const config = require("../lib/config");
|
|
23
|
+
const { formatPrompt } = require("../lib/prompt");
|
|
24
|
+
|
|
25
|
+
/** Cross-platform user home (~). */
|
|
26
|
+
function userHome() {
|
|
27
|
+
return process.env.HOME || process.env.USERPROFILE || "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Marker line that brackets the niro section appended to markdown config files
|
|
32
|
+
* (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, Windsurf global_rules.md). Single source
|
|
33
|
+
* of truth so the installer (which checks "already present?") and the uninstaller
|
|
34
|
+
* (which removes the section) can never drift apart.
|
|
35
|
+
*/
|
|
36
|
+
const NIRO_MD_MARKER = "<!-- niro-mcp -->";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The exact niro sections appended to each markdown config file. These are the single source
|
|
40
|
+
* of truth: the installer appends them and the uninstaller removes them verbatim (so a user's
|
|
41
|
+
* own notes added below the block survive uninstall — see teardown.js removeMarkerSection).
|
|
42
|
+
* Each begins with a leading "\n" because install does `existing + section`.
|
|
43
|
+
*/
|
|
44
|
+
const NIRO_MULTI_REPO_SENTENCE = "The temporary project answer is per (repo, BRANCH): after you create/switch a branch in a repo — and before your first edit on that branch — call should_create_temp_project with the repo's git url, its CURRENT branch, and its `.niro-project` id if present, then run `niro new-temp-project` in its folder if needs_temp_project is true. Re-check after every branch change (a `false` from another branch does NOT carry over); this applies to every repo you touch, including when the session started in a parent folder that is not itself a git repo.";
|
|
45
|
+
const NIRO_CLAUDE_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP tools are available for code intelligence. Use them (call chains, blast radius, impact analysis, source lookup) before falling back to native grep/read for code questions.\nBefore adding/writing any new code, you MUST use the find_reusable_code tool from niro to figure out if there is anything reusable.\n${NIRO_MULTI_REPO_SENTENCE}\n`;
|
|
46
|
+
const NIRO_CODEX_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools and the \`niro-skill\` are available (call chains, blast radius, impact analysis, source lookup). Prefer them over grep/read for questions about this codebase; the niro-skill has the full tool-routing playbook. 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_MULTI_REPO_SENTENCE}\n`;
|
|
47
|
+
const NIRO_WINDSURF_MD_SECTION = `\n${NIRO_MD_MARKER}\n## Niro MCP\nNiro MCP code-intelligence tools and the niro-skill are available. Prefer them over grep/read for questions about this codebase (structure, call chains, blast radius, impact, data flows, reuse); the niro-skill has the full routing playbook. Before writing any new code, use find_reusable_code. If the niro tools are not present or the repo is not indexed, use native tools.\n${NIRO_MULTI_REPO_SENTENCE}\n`;
|
|
48
|
+
/**
|
|
49
|
+
* Shared generic section for clients whose "always-on instructions" file has no companion
|
|
50
|
+
* skill mechanism we've verified (Gemini CLI's GEMINI.md, GitHub Copilot CLI's
|
|
51
|
+
* copilot-instructions.md, Factory's AGENTS.md, OpenCode's AGENTS.md) — one wording, reused
|
|
52
|
+
* across those destination files (teardown matches it per-file, so reuse is safe).
|
|
53
|
+
*/
|
|
54
|
+
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_MULTI_REPO_SENTENCE}\n`;
|
|
55
|
+
|
|
56
|
+
/** All niro markdown sections — the uninstaller tries each for an exact removal. */
|
|
57
|
+
const NIRO_MD_SECTIONS = [NIRO_CLAUDE_MD_SECTION, NIRO_CODEX_MD_SECTION, NIRO_WINDSURF_MD_SECTION, NIRO_GENERIC_MD_SECTION];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Upsert a niro markdown section into an agent-rules file's existing content. Returns {content, action}:
|
|
61
|
+
* - exact section already present -> {existing, "unchanged"}
|
|
62
|
+
* - no niro marker at all -> {existing + section, "added"}
|
|
63
|
+
* - marker present but content DIFFERS -> strip the old bounded niro block, append fresh, "refreshed"
|
|
64
|
+
* The "refreshed" path fixes the review bug where an existing user never received UPDATED instructions (the
|
|
65
|
+
* new temp-project sentence) because the installer skipped whenever the marker was present. The strip is
|
|
66
|
+
* bounded: it removes only from the niro block's start to the next top-level `## ` heading (or EOF), so a
|
|
67
|
+
* user's own sections below the niro block are preserved.
|
|
68
|
+
*/
|
|
69
|
+
function upsertNiroMarkerSection(existing, section) {
|
|
70
|
+
if (existing.includes(section)) return { content: existing, action: "unchanged" };
|
|
71
|
+
if (!existing.includes(NIRO_MD_MARKER)) return { content: existing + section, action: "added" };
|
|
72
|
+
const idx = existing.indexOf(NIRO_MD_MARKER);
|
|
73
|
+
let start = idx;
|
|
74
|
+
while (start > 0 && existing[start - 1] === "\n") start--;
|
|
75
|
+
const ourHeading = existing.indexOf("\n## ", idx); // niro's own "## Niro MCP" heading
|
|
76
|
+
const nextHeading = ourHeading < 0 ? -1 : existing.indexOf("\n## ", ourHeading + 4);
|
|
77
|
+
const end = nextHeading < 0 ? existing.length : nextHeading;
|
|
78
|
+
return { content: existing.slice(0, start) + existing.slice(end) + section, action: "refreshed" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True if `h` is the niro PostCompact hook entry (new nested or old flat shape).
|
|
83
|
+
* Shared by the installer's idempotency check and the uninstaller's removal filter so
|
|
84
|
+
* both agree on exactly which entries are niro's.
|
|
85
|
+
*/
|
|
86
|
+
function isNiroPostCompactHook(h) {
|
|
87
|
+
if (typeof h === "object" && h && Array.isArray(h.hooks)) {
|
|
88
|
+
return h.hooks.some((inner) => inner && typeof inner.command === "string" && inner.command.includes("Niro MCP"));
|
|
89
|
+
}
|
|
90
|
+
return typeof h === "object" && h && typeof h.command === "string" && h.command.includes("Niro MCP");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* True if `h` is a niro UserPromptSubmit hook entry — matches any niro version (old
|
|
95
|
+
* unconditional or new .niro-project-gated). Shared by installer (upgrade-in-place) and
|
|
96
|
+
* uninstaller (removal) so both target the same set.
|
|
97
|
+
*/
|
|
98
|
+
function isNiroUserPromptHook(h) {
|
|
99
|
+
const cmds = [];
|
|
100
|
+
if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
|
|
101
|
+
if (h && typeof h.command === "string") cmds.push(h.command);
|
|
102
|
+
return cmds.some((c) => c.includes(".niro-project") || c.includes("find_reusable_code") || c.includes("`niro` skill"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True if `h` is niro's PreToolUse temporary project-reminder entry. Keyed on the unique `should_create_temp_project`
|
|
107
|
+
* marker so a foreign PreToolUse hook (e.g. a user's dashboard hook) is never matched or removed.
|
|
108
|
+
* Shared by installer (upgrade-in-place) and uninstaller (removal).
|
|
109
|
+
*/
|
|
110
|
+
function isNiroPreToolUseHook(h) {
|
|
111
|
+
const cmds = [];
|
|
112
|
+
if (h && Array.isArray(h.hooks)) h.hooks.forEach((i) => i && typeof i.command === "string" && cmds.push(i.command));
|
|
113
|
+
if (h && typeof h.command === "string") cmds.push(h.command);
|
|
114
|
+
// Match the current marker AND the legacy one, so `niro mcp install`/uninstall upgrades or removes a
|
|
115
|
+
// hook installed before the sandbox→temp-project rename in place (no orphaned old hook).
|
|
116
|
+
return cmds.some((c) => c.includes("should_create_temp_project") || c.includes("should_sandbox_repo"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Writes a niro-managed skill/rule file, refreshing it when the generated content has
|
|
121
|
+
* changed. These files are GENERATED (not hand-edited), so refresh-on-change is correct:
|
|
122
|
+
* - file missing -> mkdir -p + write, returns "added"
|
|
123
|
+
* - file present, identical -> no write, returns "unchanged"
|
|
124
|
+
* - file present, differs -> overwrite, returns "updated"
|
|
125
|
+
* `label` is a short human name used in the log line (e.g. "niro skill").
|
|
126
|
+
* Returns one of: "added" | "unchanged" | "updated".
|
|
127
|
+
*/
|
|
128
|
+
function writeManagedSkill(absPath, content, label) {
|
|
129
|
+
if (!fs.existsSync(absPath)) {
|
|
130
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(absPath, content);
|
|
132
|
+
console.log(` ${label} added: ${absPath}`);
|
|
133
|
+
return "added";
|
|
134
|
+
}
|
|
135
|
+
const existing = fs.readFileSync(absPath, "utf-8");
|
|
136
|
+
if (existing === content) {
|
|
137
|
+
console.log(` ${label} already up to date — skipping (${absPath}).`);
|
|
138
|
+
return "unchanged";
|
|
139
|
+
}
|
|
140
|
+
fs.writeFileSync(absPath, content);
|
|
141
|
+
console.log(` ${label} updated: ${absPath}`);
|
|
142
|
+
return "updated";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const CLIENTS = {
|
|
146
|
+
"claude-code": {
|
|
147
|
+
name: "Claude Code",
|
|
148
|
+
description: "CLI / IDE extension",
|
|
149
|
+
},
|
|
150
|
+
codex: {
|
|
151
|
+
name: "Codex",
|
|
152
|
+
description: "OpenAI Codex CLI (writes ~/.codex/config.toml)",
|
|
153
|
+
},
|
|
154
|
+
"claude-desktop": {
|
|
155
|
+
name: "Claude Desktop",
|
|
156
|
+
description: "Desktop app",
|
|
157
|
+
configPath: () => {
|
|
158
|
+
const platform = process.platform;
|
|
159
|
+
if (platform === "darwin") return path.join(process.env.HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
160
|
+
if (platform === "win32") return path.join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
|
|
161
|
+
return path.join(process.env.HOME, ".config", "claude", "claude_desktop_config.json");
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
cursor: {
|
|
165
|
+
name: "Cursor",
|
|
166
|
+
description: "AI code editor (Composer / built-in MCP use ~/.cursor/mcp.json)",
|
|
167
|
+
/** Canonical Cursor MCP registry — NOT the Cline extension path. See https://cursor.com/docs */
|
|
168
|
+
configPath: () => path.join(userHome(), ".cursor", "mcp.json"),
|
|
169
|
+
},
|
|
170
|
+
windsurf: {
|
|
171
|
+
name: "Windsurf",
|
|
172
|
+
description: "AI code editor",
|
|
173
|
+
configPath: () => {
|
|
174
|
+
const platform = process.platform;
|
|
175
|
+
if (platform === "darwin") return path.join(process.env.HOME, ".codeium", "windsurf", "mcp_config.json");
|
|
176
|
+
if (platform === "win32") return path.join(process.env.APPDATA, ".codeium", "windsurf", "mcp_config.json");
|
|
177
|
+
return path.join(process.env.HOME, ".codeium", "windsurf", "mcp_config.json");
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
vscode: {
|
|
181
|
+
name: "VS Code",
|
|
182
|
+
description: "GitHub Copilot's native MCP support (writes the user-profile mcp.json)",
|
|
183
|
+
configPath: () => {
|
|
184
|
+
const platform = process.platform;
|
|
185
|
+
if (platform === "darwin") return path.join(process.env.HOME, "Library", "Application Support", "Code", "User", "mcp.json");
|
|
186
|
+
if (platform === "win32") return path.join(process.env.APPDATA, "Code", "User", "mcp.json");
|
|
187
|
+
return path.join(process.env.HOME, ".config", "Code", "User", "mcp.json");
|
|
188
|
+
},
|
|
189
|
+
topLevelKey: "servers",
|
|
190
|
+
buildEntry: (apiKey, mcpServerUrl) => ({
|
|
191
|
+
type: "stdio",
|
|
192
|
+
command: "niro",
|
|
193
|
+
args: ["mcp", "serve"],
|
|
194
|
+
env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
"gemini-cli": {
|
|
198
|
+
name: "Gemini CLI",
|
|
199
|
+
description: "Google's Gemini CLI (writes ~/.gemini/settings.json)",
|
|
200
|
+
},
|
|
201
|
+
"copilot-cli": {
|
|
202
|
+
name: "GitHub Copilot CLI",
|
|
203
|
+
description: "The `copilot` terminal CLI (writes ~/.copilot/mcp-config.json)",
|
|
204
|
+
},
|
|
205
|
+
factory: {
|
|
206
|
+
name: "Factory (droid)",
|
|
207
|
+
description: "Factory's droid CLI (writes ~/.factory/mcp.json)",
|
|
208
|
+
},
|
|
209
|
+
goose: {
|
|
210
|
+
name: "Goose",
|
|
211
|
+
description: "Block's Goose CLI/desktop (writes ~/.config/goose/config.yaml)",
|
|
212
|
+
},
|
|
213
|
+
opencode: {
|
|
214
|
+
name: "OpenCode",
|
|
215
|
+
description: "writes ~/.config/opencode/opencode.json",
|
|
216
|
+
configPath: () => path.join(userHome(), ".config", "opencode", "opencode.json"),
|
|
217
|
+
topLevelKey: "mcp",
|
|
218
|
+
buildEntry: (apiKey, mcpServerUrl) => ({
|
|
219
|
+
type: "local",
|
|
220
|
+
command: ["niro", "mcp", "serve"],
|
|
221
|
+
enabled: true,
|
|
222
|
+
environment: apiKey
|
|
223
|
+
? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl }
|
|
224
|
+
: { NIRO_MCP_SERVER_URL: mcpServerUrl },
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
cline: {
|
|
228
|
+
name: "Cline",
|
|
229
|
+
description: "VS Code extension (writes cline_mcp_settings.json)",
|
|
230
|
+
/**
|
|
231
|
+
* globalStorage path for publisher id `saoudrizwan.claude-dev`, under VS Code's own
|
|
232
|
+
* profile dir. Community-confirmed (cline/cline#10894), not an official Cline doc page —
|
|
233
|
+
* same caveat as the existing Cursor/Windsurf paths above.
|
|
234
|
+
*/
|
|
235
|
+
configPath: () => {
|
|
236
|
+
const platform = process.platform;
|
|
237
|
+
const globalStorage = ["Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"];
|
|
238
|
+
if (platform === "darwin") return path.join(process.env.HOME, "Library", "Application Support", ...globalStorage);
|
|
239
|
+
if (platform === "win32") return path.join(process.env.APPDATA, ...globalStorage);
|
|
240
|
+
return path.join(process.env.HOME, ".config", ...globalStorage);
|
|
241
|
+
},
|
|
242
|
+
buildEntry: (apiKey, mcpServerUrl) => ({
|
|
243
|
+
command: "niro",
|
|
244
|
+
args: ["mcp", "serve"],
|
|
245
|
+
env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
|
|
246
|
+
disabled: false,
|
|
247
|
+
autoApprove: [],
|
|
248
|
+
}),
|
|
249
|
+
},
|
|
250
|
+
kiro: {
|
|
251
|
+
name: "Kiro",
|
|
252
|
+
description: "AWS's IDE (writes ~/.kiro/settings/mcp.json)",
|
|
253
|
+
configPath: () => path.join(userHome(), ".kiro", "settings", "mcp.json"),
|
|
254
|
+
buildEntry: (apiKey, mcpServerUrl) => ({
|
|
255
|
+
command: "niro",
|
|
256
|
+
args: ["mcp", "serve"],
|
|
257
|
+
env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
|
|
258
|
+
disabled: false,
|
|
259
|
+
// Kiro's autoApprove supports a "*" wildcard for "all tools on this server" — safe here
|
|
260
|
+
// because every niro tool is read-only.
|
|
261
|
+
autoApprove: ["*"],
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
antigravity: {
|
|
265
|
+
name: "Antigravity",
|
|
266
|
+
description: "Google's IDE (writes ~/.gemini/config/mcp_config.json)",
|
|
267
|
+
configPath: () => {
|
|
268
|
+
const home = userHome();
|
|
269
|
+
const legacy = path.join(home, ".gemini", "antigravity", "mcp_config.json");
|
|
270
|
+
const unified = path.join(home, ".gemini", "config", "mcp_config.json");
|
|
271
|
+
// Antigravity 2.0 / IDE / CLI share the unified path; older IDE-only installs used the
|
|
272
|
+
// legacy one. If only the legacy file exists, keep writing there; otherwise use unified.
|
|
273
|
+
if (fs.existsSync(legacy) && !fs.existsSync(unified)) return legacy;
|
|
274
|
+
return unified;
|
|
275
|
+
},
|
|
276
|
+
buildEntry: (apiKey, mcpServerUrl) => ({
|
|
277
|
+
command: "niro",
|
|
278
|
+
args: ["mcp", "serve"],
|
|
279
|
+
env: apiKey ? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl } : { NIRO_MCP_SERVER_URL: mcpServerUrl },
|
|
280
|
+
}),
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* ~/.cursor/permissions.json — MCP tools must be allowlisted for Agent auto-run.
|
|
286
|
+
* Docs: https://cursor.com/docs/reference/permissions
|
|
287
|
+
*/
|
|
288
|
+
function userCursorDir() {
|
|
289
|
+
return path.join(userHome(), ".cursor");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Patterns that already whitelist all Niro tools (case-insensitive per Cursor). */
|
|
293
|
+
function allowlistAlreadyCoversAllNiroTools(allowlist) {
|
|
294
|
+
if (!Array.isArray(allowlist)) return false;
|
|
295
|
+
return allowlist.some((e) => {
|
|
296
|
+
if (typeof e !== "string") return false;
|
|
297
|
+
const s = e.trim().toLowerCase();
|
|
298
|
+
return s === "*:*" || s === "niro:*";
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Ensures Cursor can invoke Niro MCP tools without per-tool confirmation.
|
|
304
|
+
*
|
|
305
|
+
* Behaviour (avoids overwriting a user's richer allowlist unknowingly):
|
|
306
|
+
* - New permissions.json → write { "mcpAllowlist": ["niro:*"] }.
|
|
307
|
+
* - Existing file with valid mcpAllowlist array → append "niro:*" if missing.
|
|
308
|
+
* - Existing file without mcpAllowlist key → print instructions only (setting that key alone
|
|
309
|
+
* would replace the whole in-app MCP allowlist; merging with Cursor UI is ambiguous).
|
|
310
|
+
*
|
|
311
|
+
* Requires Cursor Agent "Auto-run" mode (not "Ask every time") — see Cursor docs.
|
|
312
|
+
*/
|
|
313
|
+
function ensureCursorPermissionsForNiro() {
|
|
314
|
+
const permPath = path.join(userCursorDir(), "permissions.json");
|
|
315
|
+
const existedBefore = fs.existsSync(permPath);
|
|
316
|
+
let data = {};
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
if (existedBefore) {
|
|
320
|
+
data = JSON.parse(fs.readFileSync(permPath, "utf8"));
|
|
321
|
+
if (!data || typeof data !== "object") data = {};
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.warn(` Could not read ${permPath}: ${e.message}`);
|
|
325
|
+
console.warn(' Fix the file manually or merge: { "mcpAllowlist": ["niro:*"] }\n');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const hasKey = Object.prototype.hasOwnProperty.call(data, "mcpAllowlist");
|
|
330
|
+
|
|
331
|
+
if (!existedBefore) {
|
|
332
|
+
data = { ...data, mcpAllowlist: ["niro:*"] };
|
|
333
|
+
} else if (hasKey && Array.isArray(data.mcpAllowlist)) {
|
|
334
|
+
if (allowlistAlreadyCoversAllNiroTools(data.mcpAllowlist)) {
|
|
335
|
+
console.log(" Cursor MCP allowlist already includes Niro (`niro:*` or broader).\n");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
data.mcpAllowlist = [...data.mcpAllowlist, "niro:*"];
|
|
339
|
+
} else if (hasKey && !Array.isArray(data.mcpAllowlist)) {
|
|
340
|
+
console.warn(" ~/.cursor/permissions.json has invalid mcpAllowlist — not modifying it.");
|
|
341
|
+
console.warn(' Add this string after fixing JSON: \"niro:*\"\n');
|
|
342
|
+
return;
|
|
343
|
+
} else if (Object.keys(data).length > 0) {
|
|
344
|
+
console.log("");
|
|
345
|
+
console.log(" ~/.cursor/permissions.json exists but has no \"mcpAllowlist\" entry.");
|
|
346
|
+
console.log(
|
|
347
|
+
' Cursor replaces the Settings MCP allowlist when you add \"mcpAllowlist\" — skipped auto-merge to avoid dropping other MCP tools.',
|
|
348
|
+
);
|
|
349
|
+
console.log(" Merge manually (include other MCP servers if you need them), e.g.:");
|
|
350
|
+
console.log(' \"mcpAllowlist\": [\"niro:*\", \"github:*\"]\n');
|
|
351
|
+
return;
|
|
352
|
+
} else {
|
|
353
|
+
data.mcpAllowlist = ["niro:*"];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!data.mcpAllowlist || !allowlistAlreadyCoversAllNiroTools(data.mcpAllowlist)) {
|
|
357
|
+
console.warn(" [niro-setup] Unexpected state writing permissions.json; skipping.\n");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
fs.mkdirSync(path.dirname(permPath), { recursive: true });
|
|
363
|
+
fs.writeFileSync(permPath, JSON.stringify(data, null, 2) + "\n");
|
|
364
|
+
console.log(` Cursor MCP permissions updated: ${permPath}`);
|
|
365
|
+
console.log(' • Added pattern \"niro:*\" so Agent can run all Niro tools without approval prompts.');
|
|
366
|
+
console.log(" • Enable Cursor Agent Auto-run / sandbox auto-run — \"Ask every time\" ignores allowlists.");
|
|
367
|
+
console.log(" • Reference: https://cursor.com/docs/reference/permissions\n");
|
|
368
|
+
} catch (e) {
|
|
369
|
+
console.warn(` Could not write ${permPath}: ${e.message}\n`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function ask(rl, question, defaultValue) {
|
|
374
|
+
const prompt = formatPrompt(question, defaultValue);
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
let done = false;
|
|
377
|
+
const finish = (answer) => {
|
|
378
|
+
if (done) return;
|
|
379
|
+
done = true;
|
|
380
|
+
rl.removeListener("close", onClose);
|
|
381
|
+
resolve((answer || "").trim() || defaultValue || "");
|
|
382
|
+
};
|
|
383
|
+
// If stdin reaches EOF (e.g. a piped/headless install) before this question is
|
|
384
|
+
// answered, readline 'close' fires and the question callback never would — resolve
|
|
385
|
+
// with the default so multi-prompt setup still completes non-interactively.
|
|
386
|
+
const onClose = () => finish("");
|
|
387
|
+
rl.once("close", onClose);
|
|
388
|
+
rl.question(prompt, (answer) => finish(answer));
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const SAAS_URL = "https://aiassistant.niroai.dev";
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Read a key=value pair out of ${INSTALL_DIR:-$HOME/niro}/.env
|
|
396
|
+
* (matches niro-distribution/install.sh convention).
|
|
397
|
+
* Returns { value, source } or null if not present.
|
|
398
|
+
*/
|
|
399
|
+
function readInstallEnvValue(key) {
|
|
400
|
+
const installDir = process.env.INSTALL_DIR || path.join(process.env.HOME || "", "niro");
|
|
401
|
+
const envFile = path.join(installDir, ".env");
|
|
402
|
+
try {
|
|
403
|
+
if (!fs.existsSync(envFile) || !fs.statSync(envFile).isFile()) return null;
|
|
404
|
+
const contents = fs.readFileSync(envFile, "utf8");
|
|
405
|
+
const re = new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`);
|
|
406
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
407
|
+
if (line.trim().startsWith("#")) continue;
|
|
408
|
+
const match = line.match(re);
|
|
409
|
+
if (match) {
|
|
410
|
+
// Strip surrounding single or double quotes if present
|
|
411
|
+
const raw = match[1].replace(/^["']|["']$/g, "");
|
|
412
|
+
return { value: raw, source: envFile };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (_e) {
|
|
416
|
+
// Fall through
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Resolve a default API key to pre-fill the setup prompt.
|
|
423
|
+
* Order:
|
|
424
|
+
* 1. NIRO_API_KEY env var
|
|
425
|
+
* 2. ${INSTALL_DIR:-$HOME/niro}/.env
|
|
426
|
+
* 3. null (user must type it)
|
|
427
|
+
*/
|
|
428
|
+
function resolveApiKey() {
|
|
429
|
+
const fromEnv = (process.env.NIRO_API_KEY || "").trim();
|
|
430
|
+
if (fromEnv) return fromEnv;
|
|
431
|
+
const found = readInstallEnvValue("NIRO_API_KEY");
|
|
432
|
+
return found ? found.value : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Detect a durable per-device CLI token written by `niro login` (~/.niro/credentials.json).
|
|
437
|
+
* When present, the MCP server authenticates with it (read at runtime from credentials.json) and
|
|
438
|
+
* NO API key needs to be configured — so setup skips the key prompt and omits NIRO_API_KEY from the
|
|
439
|
+
* written config. Returns true if a cli-token is available. The token value itself is never read out
|
|
440
|
+
* here or logged — only its presence matters for the install decision.
|
|
441
|
+
*/
|
|
442
|
+
function hasCliToken() {
|
|
443
|
+
try {
|
|
444
|
+
const credsPath = path.join(userHome(), ".niro", "credentials.json");
|
|
445
|
+
if (!fs.existsSync(credsPath)) return false;
|
|
446
|
+
const creds = JSON.parse(fs.readFileSync(credsPath, "utf8"));
|
|
447
|
+
return !!(creds && creds.cliToken && String(creds.cliToken).trim());
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Resolve the MCP server URL (ai-assistant) the editor will connect to —
|
|
456
|
+
* WITHOUT prompting. Almost every user wants the default, so asking was one
|
|
457
|
+
* wasted step per install; dedicated-backend users pass --url (or set the env
|
|
458
|
+
* var) instead. The resolved URL and where it came from are printed by the
|
|
459
|
+
* caller, so a wrong pick is visible and correctable rather than silent.
|
|
460
|
+
*
|
|
461
|
+
* Precedence: --url flag > NIRO_MCP_SERVER_URL env > ~/.niro/config.json
|
|
462
|
+
* (saved by a previous install) > SaaS default.
|
|
463
|
+
*
|
|
464
|
+
* `explicit` marks a URL the user chose THIS run (--url / env): only those are
|
|
465
|
+
* persisted to config.json. Never save the resolved default — that would pin
|
|
466
|
+
* today's SaaS URL forever and a future backend migration (new default in a
|
|
467
|
+
* new release) would silently not reach existing installs.
|
|
468
|
+
*
|
|
469
|
+
* We deliberately do NOT handle the ai-orchestrator (API) URL here: `niro login` runs first and
|
|
470
|
+
* already establishes it (saved as apiUrl in credentials.json), and the CLI reads it from there.
|
|
471
|
+
* The MCP bridge only ever talks to ai-assistant, so this is the one URL setup actually needs.
|
|
472
|
+
*
|
|
473
|
+
* `env` / `saved` are injection seams for tests; production passes only `cliUrl`.
|
|
474
|
+
*/
|
|
475
|
+
function resolveMcpServerUrl(cliUrl, env = process.env, saved = null) {
|
|
476
|
+
if (cliUrl) return { url: cliUrl, source: "--url", explicit: true };
|
|
477
|
+
if (env.NIRO_MCP_SERVER_URL) {
|
|
478
|
+
return { url: env.NIRO_MCP_SERVER_URL, source: "NIRO_MCP_SERVER_URL env var", explicit: true };
|
|
479
|
+
}
|
|
480
|
+
const cfg = saved || config.load();
|
|
481
|
+
if (cfg && cfg.mcpServerUrl) {
|
|
482
|
+
return { url: cfg.mcpServerUrl, source: `saved in ${config.FILE}`, explicit: false };
|
|
483
|
+
}
|
|
484
|
+
return { url: SAAS_URL, source: "Niro cloud default", explicit: false };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Guards the CLI-registration path (setupClaudeCode, setupCodex, and the vscode/gemini-cli/
|
|
489
|
+
* copilot-cli/factory setups below) — all of them shell out via `execSync(cmd.join(" "))`,
|
|
490
|
+
* which keeps cross-platform resolution of npm-installed `.cmd`/`.bat` shims on Windows (an
|
|
491
|
+
* `execFileSync`/no-shell rewrite would need Windows-specific handling this project doesn't
|
|
492
|
+
* have tests for). Because the resulting command string is built by joining args with a
|
|
493
|
+
* plain space and NOT quoted, an apiKey or mcpServerUrl containing shell metacharacters would
|
|
494
|
+
* be interpreted by the shell. This is a defense-in-depth boundary check — reject such values
|
|
495
|
+
* before they reach any shell-composed command, rather than attempting to escape them.
|
|
496
|
+
*/
|
|
497
|
+
function hasShellMetacharacters(value) {
|
|
498
|
+
return /[\s;&|`$()<>"'\\]/.test(value || "");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function printHeader() {
|
|
502
|
+
console.log("");
|
|
503
|
+
console.log(" Niro MCP Server Setup");
|
|
504
|
+
console.log(" ---------------------");
|
|
505
|
+
console.log("");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function printClients() {
|
|
509
|
+
console.log(" Supported clients:");
|
|
510
|
+
Object.entries(CLIENTS).forEach(([id, client]) => {
|
|
511
|
+
console.log(` ${id.padEnd(18)} ${client.name} (${client.description})`);
|
|
512
|
+
});
|
|
513
|
+
console.log("");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function setupClaudeCode(apiKey, mcpServerUrl) {
|
|
517
|
+
console.log("\n Configuring Claude Code...\n");
|
|
518
|
+
|
|
519
|
+
const cmd = [
|
|
520
|
+
"claude", "mcp", "add",
|
|
521
|
+
"--scope", "user",
|
|
522
|
+
"niro",
|
|
523
|
+
];
|
|
524
|
+
// Only embed NIRO_API_KEY when there is no cli-token (legacy/key flow). With a `niro login`
|
|
525
|
+
// session the server reads the cli-token from ~/.niro/credentials.json at runtime.
|
|
526
|
+
if (apiKey) cmd.push("-e", `NIRO_API_KEY=${apiKey}`);
|
|
527
|
+
// The bridge (src/mcp/server.js) reads NIRO_MCP_SERVER_URL (ai-assistant), not NIRO_API_URL.
|
|
528
|
+
cmd.push("-e", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
|
|
529
|
+
cmd.push("--", "niro", "mcp", "serve");
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
execSync(cmd.join(" "), { stdio: "inherit" });
|
|
533
|
+
console.log(" MCP server registered globally in Claude Code.\n");
|
|
534
|
+
} catch (err) {
|
|
535
|
+
console.error(" Failed to register MCP server. Is Claude Code installed?");
|
|
536
|
+
console.error(" Install: https://claude.ai/code\n");
|
|
537
|
+
console.error(` Manual command:\n ${cmd.join(" ")}\n`);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
addClaudeCodePostCompactHook();
|
|
542
|
+
addClaudeCodeUserPromptHook();
|
|
543
|
+
addClaudeCodePreToolUseHook();
|
|
544
|
+
addClaudeCodeMemory();
|
|
545
|
+
addClaudeCodeSkill();
|
|
546
|
+
addClaudeCodeCliSkill();
|
|
547
|
+
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Adds a PostCompact hook to ~/.claude/settings.json so Claude is reminded
|
|
553
|
+
* about Niro tools after context compaction (the point where tool awareness
|
|
554
|
+
* most commonly degrades in long sessions).
|
|
555
|
+
*/
|
|
556
|
+
function addClaudeCodePostCompactHook() {
|
|
557
|
+
const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
|
|
558
|
+
const hookCommand = "echo 'Niro MCP tools are available for code intelligence (call chains, blast radius, impact analysis, source lookup). Use niro tools before native grep/read for code questions.'";
|
|
559
|
+
|
|
560
|
+
let settings = {};
|
|
561
|
+
try {
|
|
562
|
+
if (fs.existsSync(settingsPath)) {
|
|
563
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
// Corrupt or missing — start fresh
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!settings.hooks) settings.hooks = {};
|
|
570
|
+
if (!settings.hooks.PostCompact) settings.hooks.PostCompact = [];
|
|
571
|
+
|
|
572
|
+
const alreadyPresent = settings.hooks.PostCompact.some(isNiroPostCompactHook);
|
|
573
|
+
|
|
574
|
+
if (alreadyPresent) {
|
|
575
|
+
console.log(" PostCompact hook already configured — skipping.\n");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
settings.hooks.PostCompact.push({
|
|
580
|
+
matcher: "",
|
|
581
|
+
hooks: [{ type: "command", command: hookCommand }],
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const exampleEntry = { matcher: "", hooks: [{ type: "command", command: hookCommand }] };
|
|
585
|
+
try {
|
|
586
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
587
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
588
|
+
console.log(" PostCompact hook added to ~/.claude/settings.json\n");
|
|
589
|
+
console.log(" → Claude will be reminded about Niro tools after context compaction.\n");
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
|
|
592
|
+
console.warn(" Add manually to the PostCompact array in ~/.claude/settings.json:\n");
|
|
593
|
+
console.warn(JSON.stringify(exampleEntry, null, 2) + "\n");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Appends a Niro reminder section to ~/.claude/CLAUDE.md so Claude is aware
|
|
599
|
+
* of Niro tools across all projects and sessions.
|
|
600
|
+
*/
|
|
601
|
+
function addClaudeCodeMemory() {
|
|
602
|
+
const claudeMdPath = path.join(process.env.HOME || "", ".claude", "CLAUDE.md");
|
|
603
|
+
const section = NIRO_CLAUDE_MD_SECTION;
|
|
604
|
+
|
|
605
|
+
let existing = "";
|
|
606
|
+
try {
|
|
607
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
608
|
+
existing = fs.readFileSync(claudeMdPath, "utf-8");
|
|
609
|
+
}
|
|
610
|
+
} catch {
|
|
611
|
+
// File unreadable — will create fresh
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const { content, action } = upsertNiroMarkerSection(existing, section);
|
|
615
|
+
if (action === "unchanged") {
|
|
616
|
+
console.log(" CLAUDE.md entry already up to date — skipping.\n");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
fs.mkdirSync(path.dirname(claudeMdPath), { recursive: true });
|
|
622
|
+
fs.writeFileSync(claudeMdPath, content);
|
|
623
|
+
console.log(` Niro reminder ${action === "refreshed" ? "refreshed in" : "added to"} ~/.claude/CLAUDE.md\n`);
|
|
624
|
+
console.log(" → Claude will see Niro tool guidance in every session.\n");
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.warn(` Could not write ~/.claude/CLAUDE.md: ${err.message}`);
|
|
627
|
+
console.warn(" Add manually to ~/.claude/CLAUDE.md:\n");
|
|
628
|
+
console.warn(section + "\n");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Adds a UserPromptSubmit hook to ~/.claude/settings.json so Claude is reminded
|
|
634
|
+
* about Niro tools + the `niro` skill before every prompt. This is the lightweight,
|
|
635
|
+
* always-on pointer that fixes "Claude forgets Niro mid-session"; the long-form
|
|
636
|
+
* guidance lives in the skill, so this is deliberately terse.
|
|
637
|
+
*/
|
|
638
|
+
function addClaudeCodeUserPromptHook() {
|
|
639
|
+
const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
|
|
640
|
+
// Folder-gated: walks up from the project dir and only emits the reminder when a
|
|
641
|
+
// `.niro-project` marker exists (i.e. this tree is actually indexed). The wording is
|
|
642
|
+
// conditional ("IF the niro tools are present") because a shell hook cannot see which
|
|
643
|
+
// MCP tools Claude has loaded — so it stays correct when Niro is disabled or an A/B
|
|
644
|
+
// session exposes only a subset of niro tools.
|
|
645
|
+
// Reads the prompt on stdin so it can add a SANDBOX reminder when the message asks to create/switch a
|
|
646
|
+
// branch — the exact moment a repo's local state diverges from what Niro indexed (ADR-017). The branch
|
|
647
|
+
// match is a coarse keyword heuristic (a shell hook can't call an LLM); a false positive only shows an
|
|
648
|
+
// extra harmless reminder, never gates behaviour. Base reminder is unchanged and still `.niro-project`-gated.
|
|
649
|
+
const hookCommand =
|
|
650
|
+
"IN=\"$(cat)\"; d=\"${CLAUDE_PROJECT_DIR:-$PWD}\"; while [ -n \"$d\" ] && [ \"$d\" != \"/\" ]; do " +
|
|
651
|
+
"if [ -f \"$d/.niro-project\" ]; then echo 'This repo is indexed by Niro. IF the niro MCP tools are present in your current toolset, prefer them over grep/read for codebase questions (structure, call chains, blast radius, impact, data flows, reuse) and call find_reusable_code before writing new code. If the niro tools are absent (disabled or A/B-limited) or a query is out of scope, use native tools and do not mention Niro.'; " +
|
|
652
|
+
"printf '%s' \"$IN\" | grep -qiE 'new branch|create[^.]{0,24}branch|switch[^.]{0,24}branch|checkout -b|change[^.]{0,24}branch|different branch|another branch' && " +
|
|
653
|
+
"echo 'You appear to be about to create or switch a branch. The temporary project check is per (repo, branch), so do it AFTER you are on the new branch (not before) and pass that CURRENT branch: call should_create_temp_project(git_url, branch, project_id from its .niro-project) and run niro new-temp-project if needs_temp_project is true. A check you ran on the old branch does not count.'; break; fi; " +
|
|
654
|
+
"d=\"$(dirname \"$d\")\"; done";
|
|
655
|
+
|
|
656
|
+
let settings = {};
|
|
657
|
+
try {
|
|
658
|
+
if (fs.existsSync(settingsPath)) {
|
|
659
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
// Corrupt or missing — start fresh
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (!settings.hooks) settings.hooks = {};
|
|
666
|
+
if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
|
|
667
|
+
|
|
668
|
+
// Loose signature for ANY niro UserPromptSubmit entry (old unconditional or new gated),
|
|
669
|
+
// so re-running upgrades a stale hook in place instead of leaving a duplicate.
|
|
670
|
+
const alreadyCurrent = settings.hooks.UserPromptSubmit.some(
|
|
671
|
+
(h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === hookCommand),
|
|
672
|
+
);
|
|
673
|
+
if (alreadyCurrent) {
|
|
674
|
+
console.log(" UserPromptSubmit hook already up to date — skipping.\n");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const hadStale = settings.hooks.UserPromptSubmit.some(isNiroUserPromptHook);
|
|
679
|
+
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((h) => !isNiroUserPromptHook(h));
|
|
680
|
+
// UserPromptSubmit hooks have no tool matcher — just the hooks array.
|
|
681
|
+
settings.hooks.UserPromptSubmit.push({
|
|
682
|
+
hooks: [{ type: "command", command: hookCommand }],
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
687
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
688
|
+
console.log(` UserPromptSubmit hook ${hadStale ? "upgraded" : "added"} in ~/.claude/settings.json`);
|
|
689
|
+
console.log(" → Claude is reminded about Niro before each prompt, but only in repos");
|
|
690
|
+
console.log(" that have a .niro-project marker (no nagging in un-indexed folders).\n");
|
|
691
|
+
} catch (err) {
|
|
692
|
+
console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
|
|
693
|
+
console.warn(' Add manually to the UserPromptSubmit array in ~/.claude/settings.json:\n');
|
|
694
|
+
console.warn(JSON.stringify({ hooks: [{ type: "command", command: hookCommand }] }, null, 2) + "\n");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The niro PreToolUse hook command (matcher Edit|Write). At edit time it enforces the temporary project check that
|
|
700
|
+
* prose instructions can be read past: it resolves the git repo of the file being edited, and — once per
|
|
701
|
+
* (session, repo) — injects a reminder to call should_create_temp_project for THAT repo before
|
|
702
|
+
* editing it (ADR-017). Advisory ONLY: it emits `additionalContext` with NO permissionDecision, so it
|
|
703
|
+
* never approves/denies the edit — the user's normal edit-permission flow is untouched. (Emitting
|
|
704
|
+
* permissionDecision:"allow" would AUTO-APPROVE every edit and bypass that prompt — a bug, not the goal.)
|
|
705
|
+
*
|
|
706
|
+
* Dependency-free (no jq): reads the PreToolUse JSON on stdin, pulls file_path + session_id with grep/sed,
|
|
707
|
+
* walks up for a `.niro-project` marker (so it stays silent in un-indexed repos), and dedupes via a marker
|
|
708
|
+
* file keyed by session_id + repo root. Portable grep -oE / sed -E / tr (GNU + BSD).
|
|
709
|
+
*/
|
|
710
|
+
const NIRO_PRETOOL_COMMAND = [
|
|
711
|
+
'IN="$(cat)"',
|
|
712
|
+
// file_path from tool_input; bail quietly if absent (e.g. a Write with unusual shape)
|
|
713
|
+
`f="$(printf '%s' "$IN" | grep -oE '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
|
|
714
|
+
'[ -z "$f" ] && exit 0',
|
|
715
|
+
'r="$(git -C "$(dirname "$f")" rev-parse --show-toplevel 2>/dev/null)"',
|
|
716
|
+
'[ -z "$r" ] && exit 0',
|
|
717
|
+
// only remind inside a niro-indexed tree
|
|
718
|
+
'd="$r"; ok=0; while [ -n "$d" ] && [ "$d" != "/" ]; do if [ -f "$d/.niro-project" ]; then ok=1; break; fi; d="$(dirname "$d")"; done',
|
|
719
|
+
'[ "$ok" = 0 ] && exit 0',
|
|
720
|
+
`s="$(printf '%s' "$IN" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)"/\\1/')"`,
|
|
721
|
+
// Branch-aware dedup: the temporary project answer is per (repo, BRANCH) — a "no" on main must NOT silence the
|
|
722
|
+
// reminder after the dev switches to a feature branch. Keying the once-per marker on the current branch
|
|
723
|
+
// re-fires the check on every branch change (the stale-cached-answer bug from live use).
|
|
724
|
+
'b="$(git -C "$r" rev-parse --abbrev-ref HEAD 2>/dev/null)"',
|
|
725
|
+
// Newline-delimit the three fields before hashing so distinct (session,repo,branch) triples cannot
|
|
726
|
+
// collide (e.g. session "x_a"+repo "/b" vs session "x"+repo "/a/b"). cksum sees the raw delimiter;
|
|
727
|
+
// if cksum is somehow absent, $k is empty -> one shared marker -> harmless over-dedup (advisory only).
|
|
728
|
+
`k="$(printf '%s\\n%s\\n%s' "$s" "$r" "$b" | cksum | cut -d' ' -f1)"`,
|
|
729
|
+
'm="${TMPDIR:-/tmp}/niro-tmpproj-$k"',
|
|
730
|
+
'[ -f "$m" ] && exit 0',
|
|
731
|
+
'touch "$m"',
|
|
732
|
+
// Advisory context injection ONLY — NO permissionDecision, so the user's normal edit-approval flow is
|
|
733
|
+
// untouched (permissionDecision:"allow" would auto-approve the edit). Message is quote/backtick-free.
|
|
734
|
+
`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."}}'`,
|
|
735
|
+
].join('; ');
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Adds the PreToolUse (Edit|Write) temporary project-reminder hook to ~/.claude/settings.json. Idempotent:
|
|
739
|
+
* upgrades a stale niro entry in place, never touches a foreign PreToolUse hook (matched by the
|
|
740
|
+
* should_create_temp_project marker via isNiroPreToolUseHook).
|
|
741
|
+
*/
|
|
742
|
+
function addClaudeCodePreToolUseHook() {
|
|
743
|
+
const settingsPath = path.join(process.env.HOME || "", ".claude", "settings.json");
|
|
744
|
+
let settings = {};
|
|
745
|
+
try {
|
|
746
|
+
if (fs.existsSync(settingsPath)) settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
747
|
+
} catch {
|
|
748
|
+
// Corrupt or missing — start fresh
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (!settings.hooks) settings.hooks = {};
|
|
752
|
+
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
753
|
+
|
|
754
|
+
const entry = { matcher: "Edit|Write", hooks: [{ type: "command", command: NIRO_PRETOOL_COMMAND }] };
|
|
755
|
+
const alreadyCurrent = settings.hooks.PreToolUse.some(
|
|
756
|
+
(h) => Array.isArray(h.hooks) && h.hooks.some((i) => i && i.command === NIRO_PRETOOL_COMMAND),
|
|
757
|
+
);
|
|
758
|
+
if (alreadyCurrent) {
|
|
759
|
+
console.log(" PreToolUse temporary project hook already up to date — skipping.\n");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const hadStale = settings.hooks.PreToolUse.some(isNiroPreToolUseHook);
|
|
764
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((h) => !isNiroPreToolUseHook(h));
|
|
765
|
+
settings.hooks.PreToolUse.push(entry);
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
769
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
770
|
+
console.log(` PreToolUse temporary project hook ${hadStale ? "upgraded" : "added"} in ~/.claude/settings.json`);
|
|
771
|
+
console.log(" → Before editing a Niro-indexed repo, Claude is reminded once/session to run the");
|
|
772
|
+
console.log(" should_create_temp_project check (non-blocking; silent in un-indexed repos).\n");
|
|
773
|
+
} catch (err) {
|
|
774
|
+
console.warn(` Could not write ~/.claude/settings.json: ${err.message}`);
|
|
775
|
+
console.warn(" Add manually to the PreToolUse array in ~/.claude/settings.json:\n");
|
|
776
|
+
console.warn(JSON.stringify(entry, null, 2) + "\n");
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Body of the `niro` skill (long-form tool-routing playbook). */
|
|
781
|
+
const NIRO_SKILL = `---
|
|
782
|
+
name: niro-skill
|
|
783
|
+
description: Use Niro code-intelligence MCP tools to answer questions about THIS codebase — structure, behavior, call chains, blast radius / impact, data & event flows, and reuse — instead of grep/read. Invoke whenever a task involves understanding, tracing, debugging, or assessing the impact of code, or BEFORE writing new code (check for reusable code first).
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
# Niro code intelligence
|
|
787
|
+
|
|
788
|
+
Niro indexes this codebase into a graph (AST + call/HTTP/event/data edges + LLM enrichment)
|
|
789
|
+
and exposes MCP tools under the \`niro\` server. For any question answerable from the indexed
|
|
790
|
+
code — structure, behavior, impact, data/event flows, semantics — prefer these tools over
|
|
791
|
+
native grep/read. For anything outside the indexed code (config files, docs, runtime state),
|
|
792
|
+
use your native tools.
|
|
793
|
+
|
|
794
|
+
\`project_id\` auto-resolves from a \`.niro-project\` file at the repo root — omit it. If there
|
|
795
|
+
is no such file, call \`list_projects\` and pass the chosen \`project_id\`.
|
|
796
|
+
|
|
797
|
+
## Availability self-check — read FIRST
|
|
798
|
+
|
|
799
|
+
Niro is not always applicable. Check these before relying on it, and fall back to native
|
|
800
|
+
tools **silently** (do not announce Niro's absence) whenever a check fails:
|
|
801
|
+
|
|
802
|
+
1. **Are the tools present?** Only call \`mcp__niro__*\` tools that actually appear in your
|
|
803
|
+
current toolset. During A/B sessions, or when the Niro MCP server is disabled, some or
|
|
804
|
+
all niro tools may be missing — use the next-best present tool or native grep/read.
|
|
805
|
+
2. **Is this repo indexed?** If there's no \`.niro-project\` and \`list_projects\` returns
|
|
806
|
+
nothing matching this repo — or \`get_index_status\` is not COMPLETED, or a tool reports
|
|
807
|
+
"not indexed / not found" — this codebase isn't covered. Use native tools; don't loop.
|
|
808
|
+
3. **Is the question in scope?** Structure / behavior / impact / data & event flows / reuse
|
|
809
|
+
→ Niro. Config, docs, runtime state, anything non-code → native tools.
|
|
810
|
+
|
|
811
|
+
## Cross-project queries — when the question is about a DIFFERENT repo than the cwd
|
|
812
|
+
|
|
813
|
+
\`project_id\` defaults to the CURRENT folder's project (its \`.niro-project\` pin or git remote).
|
|
814
|
+
That default is WRONG when the user asks about a repo/folder you are NOT running in — every tool call
|
|
815
|
+
would silently search the current project's graph and return empty/irrelevant results ("Niro gets
|
|
816
|
+
lost"). When the target of the question is a repo other than the current one:
|
|
817
|
+
|
|
818
|
+
1. **Identify the target** from the user's words — a repo name (e.g. \`ai-orchestrator\`), a git URL,
|
|
819
|
+
or a path that isn't the current repo.
|
|
820
|
+
2. **Find its project.** Call \`list_projects\` and match the target against each project's
|
|
821
|
+
\`repositories[]\` (compare on \`name\` and \`git_remote\`; ignore a \`.git\` suffix and treat
|
|
822
|
+
\`git@host:org/repo\` the same as \`https://host/org/repo\`).
|
|
823
|
+
3. **Resolve, never guess:**
|
|
824
|
+
- exactly one project matches → use its \`project_id\` (its \`alias\` works too);
|
|
825
|
+
- more than one matches → ASK the user which, listing each candidate by \`alias\` (or \`project_id\`)
|
|
826
|
+
and the repos it contains;
|
|
827
|
+
- none match → tell the user that repo isn't indexed under their account, and use native tools.
|
|
828
|
+
4. **Target it.** Pass the resolved \`project_id\` (or alias) as the \`project_id\` argument on EVERY
|
|
829
|
+
niro tool call for that question — it overrides the cwd default and queries that repo's graph. When a
|
|
830
|
+
later question is about the current repo again, drop \`project_id\` so it resolves from the cwd.
|
|
831
|
+
5. **Prefer structure over content-grep, and don't give up if grep is empty.** For "list / find the
|
|
832
|
+
classes/methods/handlers" questions, reach for a STRUCTURE tool (\`get_class_structure\`,
|
|
833
|
+
\`get_index_status\`, the routing tools below) — they read the graph and include annotations.
|
|
834
|
+
\`code_grep\` searches indexed source text, which a project can lack even when its graph is built. So
|
|
835
|
+
if \`code_grep\` returns nothing for a project \`list_projects\` shows as indexed, do NOT conclude the
|
|
836
|
+
code is absent and fall back to filesystem — retry the question with a structure/graph tool (same
|
|
837
|
+
\`project_id\`) first.
|
|
838
|
+
|
|
839
|
+
## Local temporary project — make Niro reflect YOUR branch + uncommitted edits
|
|
840
|
+
|
|
841
|
+
Niro indexes a repo at ONE branch (the team's tracked branch). Identity is (git url, branch): a graph
|
|
842
|
+
built on \`main\` does NOT reflect your feature branch or uncommitted edits. A **temporary project** is a private,
|
|
843
|
+
temporary index of your branch (\`niro new-temp-project\` to create, \`niro discard-temp-project\` to remove — teammates unaffected).
|
|
844
|
+
|
|
845
|
+
- **Obey \`🔔 Niro ACTION\` notices.** When a tool result appends "the coding assistant should run
|
|
846
|
+
\`niro new-temp-project\` / \`niro discard-temp-project\`", run that command with the Bash tool (cwd = that
|
|
847
|
+
repo's folder) — it's how Niro's answers catch up to your local work. These are directives, not FYIs.
|
|
848
|
+
- **Handle 🛑 refusals.** When your branch has diverged and no temporary project covers it, Niro REFUSES
|
|
849
|
+
to answer (a stale answer would describe the indexed branch, not your code) and returns three options.
|
|
850
|
+
Prefer \`niro new-temp-project\` (then retry). Only pass \`allow_stale=true\` after the USER explicitly
|
|
851
|
+
agrees to a stale answer — never on your own. Or fall back to native tools for that question.
|
|
852
|
+
- **Multi-repo work — the tool you must call.** The MCP only sees the folder this session started in, so
|
|
853
|
+
it CANNOT tell you a DIFFERENT repo you're editing needs a temporary project. YOU know which repo you're about to
|
|
854
|
+
change. **The answer is per (repo, BRANCH) — this is the #1 thing agents get wrong.** A \`needs_temp_project=false\`
|
|
855
|
+
you got while on \`main\` is meaningless once you switch to a feature branch; it does NOT carry over. So:
|
|
856
|
+
- **Check on the branch you'll actually edit, not before you switch.** If the task says "on a new branch",
|
|
857
|
+
create/switch the branch FIRST, THEN run the check on that branch. Checking while still on the old
|
|
858
|
+
branch just tells you the old branch is fine — useless.
|
|
859
|
+
- **Re-check after every branch create/switch.** Do not cache or reuse a prior result across a branch
|
|
860
|
+
change. "I already checked this repo" is wrong if you've since changed its branch.
|
|
861
|
+
- This applies even when the session started in a **parent folder that isn't itself a git repo** — then
|
|
862
|
+
EVERY repo under it is one you must check (a common miss: treating the siblings as "the project").
|
|
863
|
+
|
|
864
|
+
For each repo, once you are ON the branch you'll edit:
|
|
865
|
+
1. \`git -C <that repo's folder> remote get-url origin\`, \`git -C <that repo's folder> branch --show-current\`
|
|
866
|
+
(the CURRENT branch, after any switch); read its pin \`cat <that repo's folder>/.niro-project\` if present.
|
|
867
|
+
2. call \`should_create_temp_project(git_url, branch, dirty, project_id)\` — read-only. Pass the current \`branch\`
|
|
868
|
+
and the \`.niro-project\` \`project_id\` (without the pin it can't decide when the URL maps to several
|
|
869
|
+
projects and returns "pass project_id to disambiguate").
|
|
870
|
+
3. if it returns \`needs_temp_project=true\`, run \`niro new-temp-project\` with the Bash tool (cwd = that repo's
|
|
871
|
+
folder). All the repos your feature touches then share ONE temporary project, so cross-repo answers (a call
|
|
872
|
+
from repo A into repo B) stay coherent. Skip it when the tool says already covered, on the indexed branch,
|
|
873
|
+
or not a Niro project.
|
|
874
|
+
|
|
875
|
+
## Calling conventions (apply to every tool)
|
|
876
|
+
|
|
877
|
+
- **Partials auto-resolve.** Pass \`ClassName.method\`, a bare class name, or an HTTP path —
|
|
878
|
+
tools resolve to the best match. Do NOT pre-call \`resolve_fqn\`; use it only to disambiguate
|
|
879
|
+
a name that genuinely collides across repos.
|
|
880
|
+
- **\`purpose\` tunes the response.** Most tools take a \`purpose\` (LOCATE / ORIENT / COMPREHEND
|
|
881
|
+
/ TRACE / DEBUG / PROPAGATE / ASSESS_RISK / AUDIT / IMPLEMENT / VERIFY / SCOUT). Set it to
|
|
882
|
+
your intent to get the right fields and avoid token bloat.
|
|
883
|
+
- **Batch over loop.** Tools accept arrays (\`entity_fqns\`, \`method_fqns\`, \`sources\`,
|
|
884
|
+
\`patterns\`) — fetch many in one call instead of N calls.
|
|
885
|
+
- **Collapse stacked calls.** Pick the single most-specific tool; don't fire every relevant
|
|
886
|
+
one. \`get_blast_radius\` returns upstream callers + downstream callees + impacted files in
|
|
887
|
+
one call (it subsumes \`get_reverse_call_chain\` + \`find_handler\`); pass
|
|
888
|
+
\`include_source_code=true\` instead of a follow-up \`get_source_code\`. \`get_impact_analysis\`
|
|
889
|
+
subsumes a manual reverse-chain walk. If your plan has 3+ niro calls, re-check for a
|
|
890
|
+
subsuming tool.
|
|
891
|
+
|
|
892
|
+
## Routing — curated high-value tools, most specific wins
|
|
893
|
+
|
|
894
|
+
**Understand**
|
|
895
|
+
<!-- how_does_x_work temporarily disabled (hidden MCP tool) — re-add this row when re-enabled:
|
|
896
|
+
| How a method / endpoint works (forward flow, cross-service, events) | \`how_does_x_work\` | -->
|
|
897
|
+
| Intent | Tool |
|
|
898
|
+
|---|---|
|
|
899
|
+
| What an entity does — AI functionality docs for a method/class/module | \`explain_code\` |
|
|
900
|
+
| Exact source of a symbol or file (batch via \`sources\`) | \`get_source_code\` |
|
|
901
|
+
| Members (methods/fields) of a class/interface/enum | \`get_class_structure\` |
|
|
902
|
+
|
|
903
|
+
**Navigate & trace**
|
|
904
|
+
| Intent | Tool |
|
|
905
|
+
|---|---|
|
|
906
|
+
| What X calls (callees) — \`scope=transitive\` for deep | \`get_call_chain\` |
|
|
907
|
+
| Who calls X (callers) — \`scope=transitive\` for upstream | \`get_reverse_call_chain\` |
|
|
908
|
+
| Full path from A to B (prefer \`to_service\` for cross-service) | \`trace_execution_path\` |
|
|
909
|
+
| HTTP endpoints a service exposes | \`find_api_endpoints\` |
|
|
910
|
+
| Outbound HTTP calls a service makes | \`find_outbound_http_calls\` |
|
|
911
|
+
| What handles an outgoing HTTP call / event | \`find_handler\` |
|
|
912
|
+
|
|
913
|
+
**Change safely**
|
|
914
|
+
| Intent | Tool |
|
|
915
|
+
|---|---|
|
|
916
|
+
| BEFORE implementing/adding anything — reusable code? (pass \`steps\`) | \`find_reusable_code\` |
|
|
917
|
+
| What breaks if I change X — callers + callees, cross-service, one call | \`get_blast_radius\` |
|
|
918
|
+
| Upstream callers that break (lighter than blast radius) | \`get_impact_analysis\` |
|
|
919
|
+
|
|
920
|
+
**Debug**
|
|
921
|
+
<!-- why_could_x_happen temporarily disabled (hidden MCP tool) — re-add this row when re-enabled:
|
|
922
|
+
| Why could this fail / behave this way — candidate causes along the chain | \`why_could_x_happen\` | -->
|
|
923
|
+
| Intent | Tool |
|
|
924
|
+
|---|---|
|
|
925
|
+
| Pre-detected issues / bugs (project → service → entity) | \`find_known_issues\` |
|
|
926
|
+
| What's already initialized / authed when control reaches a method | \`get_state_at_entry\` |
|
|
927
|
+
|
|
928
|
+
**Data & events**
|
|
929
|
+
| Intent | Tool |
|
|
930
|
+
|---|---|
|
|
931
|
+
| Who reads / writes / deletes a collection, table, or bucket | \`get_data_resource_usage\` |
|
|
932
|
+
| Event producers ↔ handlers wiring | \`get_event_producers_and_handlers\` |
|
|
933
|
+
|
|
934
|
+
**Search & status**
|
|
935
|
+
| Intent | Tool |
|
|
936
|
+
|---|---|
|
|
937
|
+
| Regex / literal search in source contents (batch \`patterns\`) | \`code_grep\` |
|
|
938
|
+
| Is the project indexed? node/edge counts | \`get_index_status\` |
|
|
939
|
+
|
|
940
|
+
Also available (catalog / utility, use when the above don't fit): \`list_projects\`,
|
|
941
|
+
\`list_data_resources\`, \`list_events\`, \`find_orphaned_events\`, \`get_config_symbols\`,
|
|
942
|
+
\`resolve_fqn\`, and \`list_niro_bugs\` + \`get_bug_with_resolution\` (Niro-tracked bug RCA).
|
|
943
|
+
|
|
944
|
+
## Hard rules
|
|
945
|
+
|
|
946
|
+
- **Before adding or writing ANY new code, call \`find_reusable_code\` first.** Mandatory.
|
|
947
|
+
- For comprehension / debugging, set \`purpose\` to COMPREHEND or DEBUG and, on
|
|
948
|
+
\`get_blast_radius\` / \`get_impact_analysis\`, pass \`include_source_code=true\`.
|
|
949
|
+
- If a result contains **⚠️ ACTION REQUIRED**, the graph is stale — read the listed files
|
|
950
|
+
via \`get_source_code\` before trusting structural answers.
|
|
951
|
+
- Niro is authoritative for indexed code; data/event recall may be partial. Don't use Niro
|
|
952
|
+
for non-code questions.
|
|
953
|
+
|
|
954
|
+
## Onboarding / re-indexing a repo
|
|
955
|
+
|
|
956
|
+
Niro indexing is done outside the agent with the \`niro\` CLI (e.g. \`niro init\` in the
|
|
957
|
+
terminal), not via MCP tools. The \`niro-cli\` skill drives those CLI commands from chat and can
|
|
958
|
+
also report which backend the niro MCP connects to.
|
|
959
|
+
`;
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Body of the `niro-cli` skill — a param-collector playbook that teaches the agent to DRIVE
|
|
963
|
+
* the `niro` CLI from chat (status / onboard / edit / rebuild / remove). Unlike `niro-skill`
|
|
964
|
+
* (which routes MCP code-intelligence tools), this one runs the CLI via shell and parses --json.
|
|
965
|
+
* The agent gathers inputs in conversation, then runs ONE non-interactive command — no business
|
|
966
|
+
* logic of its own. Avoids unescaped backticks beyond the template's own use.
|
|
967
|
+
*/
|
|
968
|
+
const NIRO_CLI_SKILL = `---
|
|
969
|
+
name: niro-cli
|
|
970
|
+
description: manage Niro projects from chat: check status, onboard, edit, rebuild, remove, and show which backend the niro MCP connects to
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
# Niro CLI — manage Niro from chat
|
|
974
|
+
|
|
975
|
+
You manage a user's Niro projects by running the \`niro\` CLI via the shell (Bash) and parsing
|
|
976
|
+
its \`--json\` output. You are a PARAM-COLLECTOR, not a decision engine: gather the inputs you
|
|
977
|
+
need in conversation, then run ONE non-interactive \`niro\` command, parse the JSON, and report
|
|
978
|
+
back in plain English. Do not invent business logic — the CLI owns it.
|
|
979
|
+
|
|
980
|
+
## Golden rules (apply to EVERY task)
|
|
981
|
+
|
|
982
|
+
1. **Auth first, always.** Run \`niro whoami --json\` before anything else. It returns
|
|
983
|
+
\`{authed, email, user_id, account_id, api_url}\`. If \`authed\` is false, tell the USER to
|
|
984
|
+
run \`niro login\` THEMSELVES in their terminal — it opens a browser device flow. You cannot
|
|
985
|
+
run it, handle credentials, or complete the flow for them. STOP until they confirm they are
|
|
986
|
+
logged in, then re-run \`niro whoami --json\`.
|
|
987
|
+
2. **\`niro info --json\` is your context primitive.** Run it to learn the repo. It returns
|
|
988
|
+
\`{git_root, repo_url, branch, indexed, ambiguous, projects:[{project_id, alias, status,
|
|
989
|
+
doc_gen_status, updated_at}]}\`. \`branch\` is null when HEAD is detached. The CLI already
|
|
990
|
+
walks up to the git ROOT, so run it from anywhere inside the tree. Failure reasons include
|
|
991
|
+
\`not_a_git_repo\` and \`no_origin\`.
|
|
992
|
+
3. **Never guess which project.** If \`info.ambiguous\` is true OR \`projects\` has more than
|
|
993
|
+
one entry, ASK the user which project (by alias). Then pass it explicitly
|
|
994
|
+
(\`--project <alias>\` / \`--target <alias>\`). Never assume.
|
|
995
|
+
4. **Env-var values are secrets.** NEVER print, echo, or repeat env-var VALUES. Collect them
|
|
996
|
+
from the user privately and pass them via flags. \`niro edit --list-env\` shows non-secret
|
|
997
|
+
candidate values but MASKS secret-pattern keys (passwords/tokens/keys) — rely on that, do
|
|
998
|
+
not unmask. When choosing which file drives a var, pick by FILE NAME only; for a secret key
|
|
999
|
+
you will only ever see \`****\` for the value, never the plaintext — that is correct.
|
|
1000
|
+
5. **Confirm destructive actions explicitly.** \`niro remove --project\` deletes a project. You
|
|
1001
|
+
MUST pass \`--confirm-name <the exact alias or project_id>\`; the CLI refuses to delete
|
|
1002
|
+
without a matching name. Always confirm with the user first. \`--unmap-repo\` is
|
|
1003
|
+
non-destructive but still confirm.
|
|
1004
|
+
6. **One non-interactive command per action.** Collect inputs in chat, then run a single
|
|
1005
|
+
\`niro\` command with explicit flags (no interactive prompts). Parse \`--json\` and report.
|
|
1006
|
+
|
|
1007
|
+
## Command flows (numbered)
|
|
1008
|
+
|
|
1009
|
+
### 1. Status / "what's the state?"
|
|
1010
|
+
1. \`niro whoami --json\` (rule 1).
|
|
1011
|
+
2. \`niro info --json\` to see repo_url, branch, indexed, and the project list FOR THE CURRENT FOLDER.
|
|
1012
|
+
For ALL projects in the account (not folder-scoped — e.g. "list my projects"), run
|
|
1013
|
+
\`niro projects --json\` -> \`{projects:[{project_id, alias, repo_count, created_at}]}\`.
|
|
1014
|
+
3. For an indexed project's live build state, run \`niro status [project] --json\`
|
|
1015
|
+
(pass the alias if ambiguous, per rule 3).
|
|
1016
|
+
4. Report: authed user, repo + branch, whether indexed, project alias(es) + status +
|
|
1017
|
+
doc_gen_status, in plain English.
|
|
1018
|
+
|
|
1019
|
+
### 2. Onboard / \`niro init\`
|
|
1020
|
+
1. \`niro whoami --json\`, then \`niro info --json\` FIRST.
|
|
1021
|
+
2. If \`info.indexed\` is true: say it is already indexed — do NOT init. Offer build / edit /
|
|
1022
|
+
remove instead.
|
|
1023
|
+
3. If NOT indexed: ask whether this is a NEW project (collect a project name) or should be
|
|
1024
|
+
ADDED to an EXISTING project. To help the user choose an existing one, run
|
|
1025
|
+
\`niro projects --json\` and show their projects (alias + project_id); collect the chosen alias.
|
|
1026
|
+
4. Run non-interactively. Pick ONE of the two forms based on the choice in step 3:
|
|
1027
|
+
- NEW project (creates a project):
|
|
1028
|
+
\`niro init [path] --project-name <name> --auto-build on|off\`
|
|
1029
|
+
- EXISTING project (attaches this folder to an existing project — does NOT create a new
|
|
1030
|
+
project; \`--add-to\` takes the alias or project_id the user picked from \`niro projects --json\`):
|
|
1031
|
+
\`niro init [path] --add-to <alias|id> --auto-build on|off\`
|
|
1032
|
+
\`--add-to\` and \`--project-name\` are mutually exclusive (the CLI errors if both are passed).
|
|
1033
|
+
DUPLICATE / REUSE handling (the CLI decides this automatically):
|
|
1034
|
+
- If this repo is ALREADY in the TARGET project, init REFUSES (exit 1) — do not retry; use
|
|
1035
|
+
\`niro build\` to re-index or \`niro edit\` to manage it.
|
|
1036
|
+
- If this repo is already registered in ANOTHER project, init REUSES that repo (never
|
|
1037
|
+
re-creates it) and ATTACHES it to the target WITHOUT re-uploading the files.
|
|
1038
|
+
- Cross-project or folder-overlap WARNINGS (e.g. the same repo is a REMOTE repo elsewhere, or
|
|
1039
|
+
this folder is nested inside another tracked folder) need confirmation. Because the agent runs
|
|
1040
|
+
non-interactively, you MUST pass \`--force\` to proceed past such a warning; without it the CLI
|
|
1041
|
+
refuses (exit 1) rather than prompt.
|
|
1042
|
+
Common flags for either form:
|
|
1043
|
+
\`--quiet-period <secs> --confirm-build yes|no --defaults --no-watch --force -X <glob> -F <glob>\`
|
|
1044
|
+
Use \`--defaults\` to accept discovered env defaults; collect specific env vars LATER via
|
|
1045
|
+
\`niro edit --set-env\` (rule 4). \`-X\` excludes a module glob, \`-F\` excludes a file glob.
|
|
1046
|
+
5. ENV VARS after init — ALWAYS do this (defaults come ONLY from committed config files, so
|
|
1047
|
+
many vars have NO default, e.g. ones whose value lived in a gitignored \`.env\` or inline in
|
|
1048
|
+
code). Do it in THIS order:
|
|
1049
|
+
a. FIRST run \`niro edit --project <alias> --list-env --json\`. It returns
|
|
1050
|
+
\`{active_properties_files:[...], discovered_property_files:[{file, active}],
|
|
1051
|
+
env:[{key, source, chosen_source_file, has_user_value, has_resolved_value, is_secret,
|
|
1052
|
+
appears_in, candidates:[{source_file, value, masked, is_current, is_default}]}]}\`.
|
|
1053
|
+
For SECRET keys (\`is_secret:true\`) the candidate \`value\` is masked (\`****\`); never unmask.
|
|
1054
|
+
b. PROPERTY FILES to consider: \`discovered_property_files\` lists every property file Niro
|
|
1055
|
+
found and whether it is \`active\` (its values drive env-var defaults). Show the user these
|
|
1056
|
+
files and ASK whether any should be IGNORED (e.g. a prod-only or example file). If so, run
|
|
1057
|
+
\`niro edit --project <alias> --active-files <comma-list of files to KEEP>\` (the files NOT
|
|
1058
|
+
listed are ignored; by default all are active, and at least one file must be kept). This
|
|
1059
|
+
re-scopes which files' values become candidates — do this BEFORE collecting per-var values,
|
|
1060
|
+
then re-run --list-env.
|
|
1061
|
+
c. Show the user the \`key\` + \`source\` for each var. Flag the ones where BOTH
|
|
1062
|
+
\`has_user_value\` and \`has_resolved_value\` are false — those have no value at all and need one.
|
|
1063
|
+
d. Ask the user to provide values (privately — rule 4). For vars they want to set, run
|
|
1064
|
+
\`niro edit --project <alias> --set-env KEY=VALUE\` (repeatable). NEVER echo a value back.
|
|
1065
|
+
To adopt a specific file's value for a var, use \`--set-env-from KEY=<file>\` (pick by file
|
|
1066
|
+
name only — rule 4).
|
|
1067
|
+
e. Vars that already have a value or that the user chooses to leave at the default need no action.
|
|
1068
|
+
|
|
1069
|
+
CHOOSING WHICH FILE DRIVES A VAR (\`appears_in > 1\`): the SAME key was found in several
|
|
1070
|
+
files with different values (the \`candidates\`). Do NOT ask the user to retype the value —
|
|
1071
|
+
present the candidate FILES (by \`source_file\`) and let them pick a file. Then apply with
|
|
1072
|
+
\`niro edit --project <alias> --set-env-from KEY=<source_file>\` (repeatable). This adopts that
|
|
1073
|
+
file's value for the key. You pick by FILE NAME and NEVER handle the raw value — for secret
|
|
1074
|
+
keys the value is \`****\` anyway (rule 4). If the chosen file does not define KEY the CLI
|
|
1075
|
+
HARD-ERRORS and writes nothing; pick a file from that key's \`candidates\`.
|
|
1076
|
+
6. DETACHED HEAD (\`branch\` null): treat the folder as a local folder to add. If the user
|
|
1077
|
+
wants it watched for auto-build, pass \`--auto-build on\`.
|
|
1078
|
+
|
|
1079
|
+
### 3. Add a repo to a project / \`niro add repo\`
|
|
1080
|
+
1. Auth + \`niro info --json\`.
|
|
1081
|
+
2. Collect: sync mode (local or remote), branch, name.
|
|
1082
|
+
3. Run: \`niro add repo [path] --sync-mode local|remote --no-confirm --branch <b> --name <n>\`.
|
|
1083
|
+
4. **Remote-git-from-local-folder warning:** if \`niro info\` shows the matched project's
|
|
1084
|
+
\`source_type\` is \`REMOTE_GIT\` (registered from a remote git URL), do NOT convert it.
|
|
1085
|
+
WARN ONLY, then let the user choose:
|
|
1086
|
+
"This folder is registered as remote, so auto-builds trigger only on commits pushed to the
|
|
1087
|
+
remote. To get auto-build on local edits, add this folder as a separate local repo — but it
|
|
1088
|
+
would then be registered TWICE until automatic conversion lands (coming soon)."
|
|
1089
|
+
|
|
1090
|
+
### 4. Edit a project / \`niro edit\`
|
|
1091
|
+
Always \`--project <alias|id>\`. Pick ONE operation per run:
|
|
1092
|
+
- Add a repo: \`niro edit --project <alias> --add-repo <gitId>\`
|
|
1093
|
+
- Remove a repo: \`niro edit --project <alias> --remove-repo <gitId>\`
|
|
1094
|
+
- Set env: \`niro edit --project <alias> --set-env KEY=VALUE\` (repeatable; rule 4 —
|
|
1095
|
+
collect values privately, never echo them)
|
|
1096
|
+
- Pick env source: \`niro edit --project <alias> --set-env-from KEY=<file>\` (repeatable; adopt
|
|
1097
|
+
the value a specific committed file carries for KEY — choose by FILE NAME, never the value)
|
|
1098
|
+
- Choose property files: \`niro edit --project <alias> --active-files <comma-list>\` (only these
|
|
1099
|
+
property files' values become env-var defaults; others are ignored — default: all active, keep >=1)
|
|
1100
|
+
- List env: \`niro edit --project <alias> --list-env --json\` (per-file candidates + the
|
|
1101
|
+
discovered property files & which are active; secret values come back MASKED)
|
|
1102
|
+
- Rescan env: \`niro edit --project <alias> --rescan-env\`
|
|
1103
|
+
- Rename: \`niro edit --project <alias> --alias <newAlias> --json\`
|
|
1104
|
+
Optionally scope to a repo with \`--repo <gitId>\`.
|
|
1105
|
+
|
|
1106
|
+
### 5. Rebuild / re-index / \`niro build\`
|
|
1107
|
+
1. Auth + \`niro info --json\`; resolve the project (rule 3).
|
|
1108
|
+
2. Run: \`niro build [project] --json --no-full-rebuild --no-watch\`.
|
|
1109
|
+
Omit \`--no-full-rebuild\` for a full rebuild; omit \`--no-watch\` to keep watching.
|
|
1110
|
+
3. Report build outcome from the JSON.
|
|
1111
|
+
|
|
1112
|
+
### 6. Remove / unmap / \`niro remove\` (DESTRUCTIVE — rule 5)
|
|
1113
|
+
0. Always prefer \`niro remove --project --confirm-name <name>\` over the older \`niro delete\`: \`remove\` enforces the exact-name backstop, \`delete -y\` does not.
|
|
1114
|
+
1. Auth + \`niro info --json\`; identify the exact target (alias or project_id).
|
|
1115
|
+
2. Delete a project: confirm with the user, then run
|
|
1116
|
+
\`niro remove --target <alias|id> --project --confirm-name <exact alias-or-id>\`.
|
|
1117
|
+
The \`--confirm-name\` MUST match exactly or the CLI refuses.
|
|
1118
|
+
3. Unmap a repo (non-destructive, still confirm):
|
|
1119
|
+
\`niro remove --target <alias|id> --unmap-repo <gitId>\`.
|
|
1120
|
+
|
|
1121
|
+
### 7. Login / logout (auth lifecycle)
|
|
1122
|
+
- \`niro login\`: browser device flow. You CANNOT run this — tell the USER to run it in their
|
|
1123
|
+
terminal (rule 1).
|
|
1124
|
+
- \`niro logout\`: you may run \`niro logout\` if the user asks to sign out.
|
|
1125
|
+
|
|
1126
|
+
### 8. Which backend does the niro MCP connect to? ("where am I connected?")
|
|
1127
|
+
This inspects the EDITOR's MCP config (not the niro CLI), so it reports the backend actually in
|
|
1128
|
+
effect — including a per-project override. Run the line for the current editor; it filters out the
|
|
1129
|
+
API key so the secret never enters the conversation:
|
|
1130
|
+
- Claude Code: \`claude mcp get niro 2>&1 | grep -E '^[[:space:]]*(Scope|Status|Type):|NIRO_MCP_SERVER_URL='\`
|
|
1131
|
+
- Codex: \`codex mcp get niro 2>/dev/null | grep -iE 'url|status' || grep -i NIRO_MCP_SERVER_URL ~/.codex/config.toml\`
|
|
1132
|
+
- Otherwise: grep \`NIRO_MCP_SERVER_URL\` in the editor's MCP config file.
|
|
1133
|
+
Report the effective \`NIRO_MCP_SERVER_URL\` (the ai-assistant the MCP connects to), the config
|
|
1134
|
+
scope (user vs project) and whether it is Connected. NEVER print, echo, or repeat \`NIRO_API_KEY\`;
|
|
1135
|
+
if it ever appears in output, redact it.
|
|
1136
|
+
|
|
1137
|
+
## Reporting
|
|
1138
|
+
After each command, parse the \`--json\` and summarize in plain English: what changed, the new
|
|
1139
|
+
state, and the next sensible step. Never dump raw secrets or env values.
|
|
1140
|
+
`;
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Writes the `niro` skill to ~/.claude/skills/niro-skill/SKILL.md.
|
|
1144
|
+
* This file is niro-generated; it is refreshed when the bundled content changes.
|
|
1145
|
+
*/
|
|
1146
|
+
function addClaudeCodeSkill() {
|
|
1147
|
+
const skillPath = path.join(process.env.HOME || "", ".claude", "skills", "niro-skill", "SKILL.md");
|
|
1148
|
+
try {
|
|
1149
|
+
const result = writeManagedSkill(skillPath, NIRO_SKILL, "niro skill");
|
|
1150
|
+
if (result === "added" || result === "updated") {
|
|
1151
|
+
console.log(" → Claude loads the full Niro tool-routing playbook on demand.");
|
|
1152
|
+
}
|
|
1153
|
+
console.log("");
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
console.warn(` Could not write ~/.claude/skills/niro-skill/SKILL.md: ${err.message}\n`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Writes the `niro-cli` skill to ~/.claude/skills/niro-cli/SKILL.md.
|
|
1161
|
+
* This file is niro-generated; it is refreshed when the bundled content changes.
|
|
1162
|
+
* Mirrors addClaudeCodeSkill.
|
|
1163
|
+
*/
|
|
1164
|
+
function addClaudeCodeCliSkill() {
|
|
1165
|
+
const skillPath = path.join(process.env.HOME || "", ".claude", "skills", "niro-cli", "SKILL.md");
|
|
1166
|
+
try {
|
|
1167
|
+
const result = writeManagedSkill(skillPath, NIRO_CLI_SKILL, "niro-cli skill");
|
|
1168
|
+
if (result === "added" || result === "updated") {
|
|
1169
|
+
console.log(" → Claude can drive the niro CLI from chat (status / onboard / edit / rebuild / remove).");
|
|
1170
|
+
}
|
|
1171
|
+
console.log("");
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
console.warn(` Could not write ~/.claude/skills/niro-cli/SKILL.md: ${err.message}\n`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Body of the Cursor project rule (.cursor/rules/niro.mdc).
|
|
1179
|
+
* Agent-requested rule: `alwaysApply: false` + a strong `description`, so the description
|
|
1180
|
+
* acts as the always-on nudge (Cursor's nearest analog to a UserPromptSubmit hook) and the
|
|
1181
|
+
* body loads on demand (analog to the Claude Code skill). Avoids backticks so it can live
|
|
1182
|
+
* in a JS template literal without escaping.
|
|
1183
|
+
*/
|
|
1184
|
+
const NIRO_CURSOR_RULE = `---
|
|
1185
|
+
description: Niro code-intelligence MCP tools for THIS codebase. Use them for questions about structure, call chains, blast radius, impact, data flows, and reuse — before grep/read. Before writing new code, run find_reusable_code.
|
|
1186
|
+
alwaysApply: false
|
|
1187
|
+
---
|
|
1188
|
+
|
|
1189
|
+
# Niro code intelligence
|
|
1190
|
+
|
|
1191
|
+
The niro MCP server exposes code-intelligence tools for this codebase. Prefer them over
|
|
1192
|
+
grep/read for any question answerable from the indexed code.
|
|
1193
|
+
|
|
1194
|
+
Availability self-check FIRST — if the niro tools are not present in this session, or the
|
|
1195
|
+
repo is not indexed (no project resolves / a tool reports "not indexed"), fall back to
|
|
1196
|
+
native tools silently and do not mention Niro.
|
|
1197
|
+
|
|
1198
|
+
Conventions: partials auto-resolve (pass ClassName.method or an HTTP path; do NOT pre-call
|
|
1199
|
+
resolve_fqn). Most tools take a purpose arg (COMPREHEND / DEBUG / TRACE / ASSESS_RISK / ...)
|
|
1200
|
+
that tunes the response. Batch via arrays (entity_fqns, method_fqns, sources, patterns).
|
|
1201
|
+
|
|
1202
|
+
Routing (most specific wins):
|
|
1203
|
+
- before writing/adding any code: find_reusable_code (mandatory; pass a steps array)
|
|
1204
|
+
- what-breaks-if-I-change-X: get_blast_radius (callers + callees + impacted files in one
|
|
1205
|
+
call; subsumes reverse-call-chain + handler; pass include_source_code=true for COMPREHEND/DEBUG)
|
|
1206
|
+
- upstream callers that break: get_impact_analysis
|
|
1207
|
+
<!-- how_does_x_work temporarily disabled (hidden MCP tool) — re-add "how_does_x_work (forward flow) | " to the understand line below when re-enabled -->
|
|
1208
|
+
- understand: explain_code (AI docs) | get_source_code | get_class_structure
|
|
1209
|
+
- navigate: get_call_chain (callees) | get_reverse_call_chain (callers) | trace_execution_path (A to B)
|
|
1210
|
+
- HTTP: find_api_endpoints | find_outbound_http_calls | find_handler
|
|
1211
|
+
<!-- why_could_x_happen temporarily disabled (hidden MCP tool) — re-add "why_could_x_happen | " to the debug line below when re-enabled -->
|
|
1212
|
+
- debug: find_known_issues | get_state_at_entry
|
|
1213
|
+
- data & events: get_data_resource_usage | get_event_producers_and_handlers
|
|
1214
|
+
- search / status: code_grep | get_index_status
|
|
1215
|
+
|
|
1216
|
+
Onboarding / indexing a repo is done in the terminal with the niro CLI (niro init), not
|
|
1217
|
+
via MCP tools.
|
|
1218
|
+
`;
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Writes the Niro Cursor rule to <cwd>/.cursor/rules/niro.mdc. Cursor rules are
|
|
1222
|
+
* PROJECT-scoped (unlike the global ~/.cursor/mcp.json), so this lands in the repo the
|
|
1223
|
+
* wizard is run from. Skips if the file already exists, to preserve user edits.
|
|
1224
|
+
*/
|
|
1225
|
+
function ensureCursorRule() {
|
|
1226
|
+
const rulePath = path.join(process.cwd(), ".cursor", "rules", "niro.mdc");
|
|
1227
|
+
if (fs.existsSync(rulePath)) {
|
|
1228
|
+
console.log(" Cursor rule already present — skipping (.cursor/rules/niro.mdc).\n");
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
try {
|
|
1232
|
+
fs.mkdirSync(path.dirname(rulePath), { recursive: true });
|
|
1233
|
+
fs.writeFileSync(rulePath, NIRO_CURSOR_RULE);
|
|
1234
|
+
console.log(` Cursor rule added: ${rulePath}`);
|
|
1235
|
+
console.log(" • Project-scoped. For other repos, copy it or re-run setup there.\n");
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
console.warn(` Could not write .cursor/rules/niro.mdc: ${err.message}\n`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Registers the Niro MCP server with OpenAI Codex by running `codex mcp add`, which writes
|
|
1243
|
+
* the [mcp_servers.niro] table into ~/.codex/config.toml. Mirrors setupClaudeCode.
|
|
1244
|
+
*/
|
|
1245
|
+
async function setupCodex(apiKey, mcpServerUrl) {
|
|
1246
|
+
console.log("\n Configuring Codex...\n");
|
|
1247
|
+
|
|
1248
|
+
const cmd = [
|
|
1249
|
+
"codex", "mcp", "add",
|
|
1250
|
+
"niro",
|
|
1251
|
+
];
|
|
1252
|
+
if (apiKey) cmd.push("--env", `NIRO_API_KEY=${apiKey}`);
|
|
1253
|
+
// The bridge reads NIRO_MCP_SERVER_URL (ai-assistant), not NIRO_API_URL.
|
|
1254
|
+
cmd.push("--env", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
|
|
1255
|
+
cmd.push("--", "niro", "mcp", "serve");
|
|
1256
|
+
|
|
1257
|
+
try {
|
|
1258
|
+
execSync(cmd.join(" "), { stdio: "inherit" });
|
|
1259
|
+
console.log(" MCP server registered in Codex (~/.codex/config.toml).\n");
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
console.error(" Failed to register MCP server. Is the Codex CLI installed?");
|
|
1262
|
+
console.error(" Install: https://developers.openai.com/codex\n");
|
|
1263
|
+
console.error(` Manual command:\n ${cmd.join(" ")}\n`);
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
hardenCodexServerConfig();
|
|
1268
|
+
addCodexMemory();
|
|
1269
|
+
addAgentsSkill();
|
|
1270
|
+
addAgentsCliSkill();
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* After `codex mcp add` writes [mcp_servers.niro], set
|
|
1276
|
+
* `default_tools_approval_mode = "auto"` — niro tools are all read-only, so no per-tool
|
|
1277
|
+
* approval prompts (codex mcp add has no flag for this; we insert it directly after the
|
|
1278
|
+
* table header, which is valid TOML — bare keys precede any sub-table). Idempotent and a
|
|
1279
|
+
* safe no-op if the file/table is missing. Also prints the optional `required = true`
|
|
1280
|
+
* recommendation (left off by default: it would make niro a hard Codex startup dependency).
|
|
1281
|
+
*/
|
|
1282
|
+
function hardenCodexServerConfig() {
|
|
1283
|
+
const cfgPath = path.join(process.env.HOME || "", ".codex", "config.toml");
|
|
1284
|
+
const header = "[mcp_servers.niro]";
|
|
1285
|
+
|
|
1286
|
+
let toml;
|
|
1287
|
+
try {
|
|
1288
|
+
toml = fs.readFileSync(cfgPath, "utf-8");
|
|
1289
|
+
} catch {
|
|
1290
|
+
console.warn(" Could not read ~/.codex/config.toml to set the approval mode — skipping.\n");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const idx = toml.indexOf(header);
|
|
1295
|
+
if (idx === -1) {
|
|
1296
|
+
console.warn(" [mcp_servers.niro] not found in config.toml — skipping approval-mode hardening.\n");
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Limit the "already set?" check to the niro table body (up to the next table header).
|
|
1301
|
+
const after = toml.slice(idx + header.length);
|
|
1302
|
+
const nextTableRel = after.search(/\n\s*\[/);
|
|
1303
|
+
const tableBody = nextTableRel === -1 ? after : after.slice(0, nextTableRel);
|
|
1304
|
+
|
|
1305
|
+
console.log(" Tip: for a hard dependency, add `required = true` under [mcp_servers.niro]");
|
|
1306
|
+
console.log(" (Codex then fails startup if niro is unreachable — off by default).\n");
|
|
1307
|
+
|
|
1308
|
+
if (/^\s*default_tools_approval_mode\s*=/m.test(tableBody)) {
|
|
1309
|
+
console.log(" Codex approval mode already set — skipping.\n");
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const insertAt = idx + header.length;
|
|
1314
|
+
const newToml = toml.slice(0, insertAt) + '\ndefault_tools_approval_mode = "auto"' + toml.slice(insertAt);
|
|
1315
|
+
|
|
1316
|
+
try {
|
|
1317
|
+
fs.writeFileSync(cfgPath, newToml);
|
|
1318
|
+
console.log(' Set default_tools_approval_mode = "auto" in ~/.codex/config.toml');
|
|
1319
|
+
console.log(" → niro is read-only, so its tools run without per-call approval prompts.\n");
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
console.warn(` Could not update ~/.codex/config.toml: ${err.message}\n`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Appends a Niro reminder to ~/.codex/AGENTS.md (Codex's persistent global instructions —
|
|
1327
|
+
* its analog to ~/.claude/CLAUDE.md). Codex has no skills/hooks, so AGENTS.md is the lever
|
|
1328
|
+
* for cross-session tool awareness. Idempotent via a marker.
|
|
1329
|
+
*/
|
|
1330
|
+
function addCodexMemory() {
|
|
1331
|
+
const agentsPath = path.join(process.env.HOME || "", ".codex", "AGENTS.md");
|
|
1332
|
+
const section = NIRO_CODEX_MD_SECTION;
|
|
1333
|
+
|
|
1334
|
+
let existing = "";
|
|
1335
|
+
try {
|
|
1336
|
+
if (fs.existsSync(agentsPath)) {
|
|
1337
|
+
existing = fs.readFileSync(agentsPath, "utf-8");
|
|
1338
|
+
}
|
|
1339
|
+
} catch {
|
|
1340
|
+
// File unreadable — will create fresh
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const { content, action } = upsertNiroMarkerSection(existing, section);
|
|
1344
|
+
if (action === "unchanged") {
|
|
1345
|
+
console.log(" AGENTS.md entry already up to date — skipping.\n");
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
fs.mkdirSync(path.dirname(agentsPath), { recursive: true });
|
|
1351
|
+
fs.writeFileSync(agentsPath, content);
|
|
1352
|
+
console.log(` Niro reminder ${action === "refreshed" ? "refreshed in" : "added to"} ~/.codex/AGENTS.md`);
|
|
1353
|
+
console.log(" → Codex will see Niro tool guidance across sessions.\n");
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
console.warn(` Could not write ~/.codex/AGENTS.md: ${err.message}`);
|
|
1356
|
+
console.warn(" Add manually to ~/.codex/AGENTS.md:\n");
|
|
1357
|
+
console.warn(section + "\n");
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Writes the niro-skill to ~/.agents/skills/niro-skill/SKILL.md — the cross-agent skills
|
|
1363
|
+
* location read by BOTH Codex (user-level ~/.agents/skills) and Windsurf. One file gives
|
|
1364
|
+
* both tools the same on-demand, description-triggered playbook the Claude Code skill
|
|
1365
|
+
* provides. Reuses the NIRO_SKILL body. This file is niro-generated; refreshed on change.
|
|
1366
|
+
*/
|
|
1367
|
+
function addAgentsSkill() {
|
|
1368
|
+
const skillPath = path.join(process.env.HOME || "", ".agents", "skills", "niro-skill", "SKILL.md");
|
|
1369
|
+
try {
|
|
1370
|
+
const result = writeManagedSkill(skillPath, NIRO_SKILL, "Shared niro-skill");
|
|
1371
|
+
if (result === "added" || result === "updated") {
|
|
1372
|
+
console.log(" • Read by Codex and Windsurf (cross-agent skills location).");
|
|
1373
|
+
}
|
|
1374
|
+
console.log("");
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
console.warn(` Could not write ~/.agents/skills/niro-skill/SKILL.md: ${err.message}\n`);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Writes the niro-cli skill to ~/.agents/skills/niro-cli/SKILL.md — the cross-agent skills
|
|
1382
|
+
* location read by BOTH Codex and Windsurf. Mirrors addAgentsSkill but for the CLI-driving
|
|
1383
|
+
* playbook. Reuses NIRO_CLI_SKILL. This file is niro-generated; refreshed on change.
|
|
1384
|
+
*/
|
|
1385
|
+
function addAgentsCliSkill() {
|
|
1386
|
+
const skillPath = path.join(process.env.HOME || "", ".agents", "skills", "niro-cli", "SKILL.md");
|
|
1387
|
+
try {
|
|
1388
|
+
const result = writeManagedSkill(skillPath, NIRO_CLI_SKILL, "Shared niro-cli skill");
|
|
1389
|
+
if (result === "added" || result === "updated") {
|
|
1390
|
+
console.log(" • Read by Codex and Windsurf (cross-agent skills location).");
|
|
1391
|
+
}
|
|
1392
|
+
console.log("");
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
console.warn(` Could not write ~/.agents/skills/niro-cli/SKILL.md: ${err.message}\n`);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Appends a Niro reminder to ~/.codeium/windsurf/memories/global_rules.md — Windsurf's
|
|
1400
|
+
* always-on global rules file (its analog to the Claude Code hook + CLAUDE.md). The
|
|
1401
|
+
* niro-skill carries the detail; this is the terse always-on pointer. Idempotent via marker.
|
|
1402
|
+
*/
|
|
1403
|
+
function addWindsurfGlobalRule() {
|
|
1404
|
+
const rulePath = path.join(process.env.HOME || "", ".codeium", "windsurf", "memories", "global_rules.md");
|
|
1405
|
+
const section = NIRO_WINDSURF_MD_SECTION;
|
|
1406
|
+
|
|
1407
|
+
let existing = "";
|
|
1408
|
+
try {
|
|
1409
|
+
if (fs.existsSync(rulePath)) existing = fs.readFileSync(rulePath, "utf-8");
|
|
1410
|
+
} catch {
|
|
1411
|
+
// unreadable — will create fresh
|
|
1412
|
+
}
|
|
1413
|
+
const { content, action } = upsertNiroMarkerSection(existing, section);
|
|
1414
|
+
if (action === "unchanged") {
|
|
1415
|
+
console.log(" Windsurf global rule already up to date — skipping.\n");
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
try {
|
|
1419
|
+
fs.mkdirSync(path.dirname(rulePath), { recursive: true });
|
|
1420
|
+
fs.writeFileSync(rulePath, content);
|
|
1421
|
+
console.log(` Niro reminder ${action === "refreshed" ? "refreshed in" : "added to"} ~/.codeium/windsurf/memories/global_rules.md`);
|
|
1422
|
+
console.log(" → Windsurf stays aware of Niro across sessions.\n");
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
console.warn(` Could not write Windsurf global_rules.md: ${err.message}\n`);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Appends `section` (idempotent via NIRO_MD_MARKER) to any client's always-on markdown
|
|
1430
|
+
* instructions file at `absPath`, creating the file (and its directory) if needed. This is
|
|
1431
|
+
* the generic version of addCodexMemory/addWindsurfGlobalRule for clients whose "memory"
|
|
1432
|
+
* file has no client-specific extras (skills, hooks) — just the terse reminder.
|
|
1433
|
+
*/
|
|
1434
|
+
function appendMarkerSectionToFile(absPath, label, section = NIRO_GENERIC_MD_SECTION) {
|
|
1435
|
+
let existing = "";
|
|
1436
|
+
try {
|
|
1437
|
+
if (fs.existsSync(absPath)) existing = fs.readFileSync(absPath, "utf-8");
|
|
1438
|
+
} catch {
|
|
1439
|
+
// unreadable — will create fresh
|
|
1440
|
+
}
|
|
1441
|
+
const { content, action } = upsertNiroMarkerSection(existing, section);
|
|
1442
|
+
if (action === "unchanged") {
|
|
1443
|
+
console.log(` ${label} entry already up to date — skipping.\n`);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
try {
|
|
1447
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
1448
|
+
fs.writeFileSync(absPath, content);
|
|
1449
|
+
console.log(` Niro reminder ${action === "refreshed" ? "refreshed in" : "added to"} ${absPath}\n`);
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
console.warn(` Could not write ${absPath}: ${err.message}\n`);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function addGeminiMemory() {
|
|
1456
|
+
appendMarkerSectionToFile(path.join(userHome(), ".gemini", "GEMINI.md"), "GEMINI.md");
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function addCopilotCliMemory() {
|
|
1460
|
+
appendMarkerSectionToFile(path.join(userHome(), ".copilot", "copilot-instructions.md"), "Copilot CLI instructions");
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function addFactoryMemory() {
|
|
1464
|
+
appendMarkerSectionToFile(path.join(userHome(), ".factory", "AGENTS.md"), "Factory AGENTS.md");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function addOpenCodeMemory() {
|
|
1468
|
+
appendMarkerSectionToFile(path.join(userHome(), ".config", "opencode", "AGENTS.md"), "OpenCode AGENTS.md");
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Body of the Cline rule file (Cline's global "Rules" dir — its analog to Cursor's project
|
|
1473
|
+
* rule). Global, not project-scoped, so one write covers every repo.
|
|
1474
|
+
*/
|
|
1475
|
+
const NIRO_CLINE_RULE = `# Niro MCP
|
|
1476
|
+
|
|
1477
|
+
Niro code-intelligence MCP tools are available for the current codebase. Prefer them over
|
|
1478
|
+
grep/read for questions about structure, call chains, blast radius, impact, data flows, and
|
|
1479
|
+
reuse. Before writing or adding any new code, use \`find_reusable_code\` first.
|
|
1480
|
+
|
|
1481
|
+
If the niro tools are not present in this session, or the repo is not indexed (no project
|
|
1482
|
+
resolves), fall back to native tools silently — do not mention Niro's absence.
|
|
1483
|
+
`;
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Writes the Niro rule to Cline's global Rules directory (~/Documents/Cline/Rules on
|
|
1487
|
+
* macOS/most Linux setups — see cline/cline#5153 for the WSL/some-Linux variant using
|
|
1488
|
+
* ~/Cline/Rules directly). Applies to every repo, unlike Cursor's project-scoped rule.
|
|
1489
|
+
*/
|
|
1490
|
+
function addClineRule() {
|
|
1491
|
+
const home = userHome();
|
|
1492
|
+
const rulePath = path.join(home, "Documents", "Cline", "Rules", "niro.md");
|
|
1493
|
+
try {
|
|
1494
|
+
const result = writeManagedSkill(rulePath, NIRO_CLINE_RULE, "Cline rule");
|
|
1495
|
+
if (result === "added" || result === "updated") {
|
|
1496
|
+
console.log(" • Global — applies across all your repos in Cline.");
|
|
1497
|
+
}
|
|
1498
|
+
console.log("");
|
|
1499
|
+
} catch (err) {
|
|
1500
|
+
console.warn(` Could not write Cline rule: ${err.message}`);
|
|
1501
|
+
console.warn(` If Cline uses ~/Cline/Rules instead of ~/Documents/Cline/Rules on your system, copy it there.\n`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Body of the Kiro steering file. `inclusion: always` makes it apply to every conversation,
|
|
1507
|
+
* global (~/.kiro/steering), same as Cline's rule above.
|
|
1508
|
+
*/
|
|
1509
|
+
const NIRO_KIRO_STEERING = `---
|
|
1510
|
+
inclusion: always
|
|
1511
|
+
---
|
|
1512
|
+
|
|
1513
|
+
# Niro MCP
|
|
1514
|
+
|
|
1515
|
+
Niro code-intelligence MCP tools are available for the current codebase. Prefer them over
|
|
1516
|
+
grep/read for questions about structure, call chains, blast radius, impact, data flows, and
|
|
1517
|
+
reuse. Before writing or adding any new code, use \`find_reusable_code\` first.
|
|
1518
|
+
|
|
1519
|
+
If the niro tools are not present in this session, or the repo is not indexed (no project
|
|
1520
|
+
resolves), fall back to native tools silently — do not mention Niro's absence.
|
|
1521
|
+
`;
|
|
1522
|
+
|
|
1523
|
+
function addKiroSteering() {
|
|
1524
|
+
const rulePath = path.join(userHome(), ".kiro", "steering", "niro.md");
|
|
1525
|
+
try {
|
|
1526
|
+
const result = writeManagedSkill(rulePath, NIRO_KIRO_STEERING, "Kiro steering file");
|
|
1527
|
+
if (result === "added" || result === "updated") {
|
|
1528
|
+
console.log(" • Global — applies across all your repos in Kiro.");
|
|
1529
|
+
}
|
|
1530
|
+
console.log("");
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
console.warn(` Could not write Kiro steering file: ${err.message}\n`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Registers the niro MCP entry with Google's Gemini CLI (writes ~/.gemini/settings.json).
|
|
1538
|
+
* `-s user` makes it a global (not project) registration, matching every other client here.
|
|
1539
|
+
*/
|
|
1540
|
+
async function setupGeminiCli(apiKey, mcpServerUrl) {
|
|
1541
|
+
console.log("\n Configuring Gemini CLI...\n");
|
|
1542
|
+
|
|
1543
|
+
const cmd = ["gemini", "mcp", "add", "-s", "user"];
|
|
1544
|
+
if (apiKey) cmd.push("-e", `NIRO_API_KEY=${apiKey}`);
|
|
1545
|
+
cmd.push("-e", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
|
|
1546
|
+
cmd.push("niro", "niro", "mcp", "serve");
|
|
1547
|
+
|
|
1548
|
+
try {
|
|
1549
|
+
execSync(cmd.join(" "), { stdio: "inherit" });
|
|
1550
|
+
console.log(" MCP server registered in Gemini CLI (~/.gemini/settings.json).\n");
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
console.error(" Failed to register MCP server. Is the Gemini CLI installed?");
|
|
1553
|
+
console.error(" Install: https://github.com/google-gemini/gemini-cli\n");
|
|
1554
|
+
console.error(` Manual command:\n ${cmd.join(" ")}\n`);
|
|
1555
|
+
return false;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
addGeminiMemory();
|
|
1559
|
+
console.log(" Run `/mcp reload` inside Gemini CLI so the server + memory reload.\n");
|
|
1560
|
+
return true;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* Registers the niro MCP entry with the GitHub Copilot CLI (writes ~/.copilot/mcp-config.json).
|
|
1565
|
+
*/
|
|
1566
|
+
async function setupCopilotCli(apiKey, mcpServerUrl) {
|
|
1567
|
+
console.log("\n Configuring GitHub Copilot CLI...\n");
|
|
1568
|
+
|
|
1569
|
+
const cmd = ["copilot", "mcp", "add", "niro"];
|
|
1570
|
+
if (apiKey) cmd.push("--env", `NIRO_API_KEY=${apiKey}`);
|
|
1571
|
+
cmd.push("--env", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
|
|
1572
|
+
cmd.push("--", "niro", "mcp", "serve");
|
|
1573
|
+
|
|
1574
|
+
try {
|
|
1575
|
+
execSync(cmd.join(" "), { stdio: "inherit" });
|
|
1576
|
+
console.log(" MCP server registered in Copilot CLI (~/.copilot/mcp-config.json).\n");
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
console.error(" Failed to register MCP server. Is the Copilot CLI installed (npm i -g @github/copilot)?\n");
|
|
1579
|
+
console.error(` Manual command:\n ${cmd.join(" ")}\n`);
|
|
1580
|
+
return false;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
addCopilotCliMemory();
|
|
1584
|
+
console.log(" Run `/mcp` inside the Copilot CLI to verify — servers are available immediately.\n");
|
|
1585
|
+
return true;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Registers the niro MCP entry with Factory's droid CLI (writes ~/.factory/mcp.json).
|
|
1590
|
+
* The server command is passed as ONE quoted argument per droid's documented syntax
|
|
1591
|
+
* (`droid mcp add <name> "<command>" --env ...`) — the quoted string here is a fixed literal
|
|
1592
|
+
* ("niro mcp serve"), not user input, so static quoting is safe.
|
|
1593
|
+
*/
|
|
1594
|
+
async function setupFactory(apiKey, mcpServerUrl) {
|
|
1595
|
+
console.log("\n Configuring Factory (droid)...\n");
|
|
1596
|
+
|
|
1597
|
+
const cmd = ["droid", "mcp", "add", "niro", '"niro mcp serve"'];
|
|
1598
|
+
if (apiKey) cmd.push("--env", `NIRO_API_KEY=${apiKey}`);
|
|
1599
|
+
cmd.push("--env", `NIRO_MCP_SERVER_URL=${mcpServerUrl}`);
|
|
1600
|
+
|
|
1601
|
+
try {
|
|
1602
|
+
execSync(cmd.join(" "), { stdio: "inherit" });
|
|
1603
|
+
console.log(" MCP server registered in Factory (~/.factory/mcp.json).\n");
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
console.error(" Failed to register MCP server. Is the droid CLI installed?");
|
|
1606
|
+
console.error(" Install: https://docs.factory.ai\n");
|
|
1607
|
+
console.error(` Manual command:\n ${cmd.join(" ")}\n`);
|
|
1608
|
+
return false;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
addFactoryMemory();
|
|
1612
|
+
console.log(" droid reloads automatically when ~/.factory/mcp.json changes.\n");
|
|
1613
|
+
return true;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Registers the niro MCP entry in Goose's config.yaml (~/.config/goose/config.yaml, or
|
|
1618
|
+
* %APPDATA%\\Block\\goose\\config\\config.yaml on Windows). Goose has no `goose configure
|
|
1619
|
+
* --non-interactive` equivalent, so this writes YAML directly rather than adding a YAML
|
|
1620
|
+
* parsing dependency (none exists in package.json today — see Dependency Governance in
|
|
1621
|
+
* CLAUDE.md) for one client. To avoid corrupting a user's existing file, the automatic write
|
|
1622
|
+
* only handles the common cases (no file yet, or a file with no top-level `extensions:` key,
|
|
1623
|
+
* or `extensions:` already present but without a `niro` entry at the standard 2-space indent
|
|
1624
|
+
* Goose's own docs use); anything else — mirroring the bounded-insertion caution used for
|
|
1625
|
+
* markdown files in teardown.js — falls back to printing the block for the user to paste in.
|
|
1626
|
+
*/
|
|
1627
|
+
function buildGooseNiroBlock(apiKey, mcpServerUrl) {
|
|
1628
|
+
// Callers (setupGoose, dispatched only from main()) already ran both values through
|
|
1629
|
+
// hasShellMetacharacters, which also rejects the quotes/backslashes that would break this
|
|
1630
|
+
// YAML string — safe to embed directly.
|
|
1631
|
+
const envs = [" envs:", ` NIRO_MCP_SERVER_URL: "${mcpServerUrl}"`];
|
|
1632
|
+
if (apiKey) envs.push(` NIRO_API_KEY: "${apiKey}"`);
|
|
1633
|
+
return [
|
|
1634
|
+
" niro:",
|
|
1635
|
+
" name: niro",
|
|
1636
|
+
" display_name: Niro",
|
|
1637
|
+
" cmd: niro",
|
|
1638
|
+
' args: ["mcp", "serve"]',
|
|
1639
|
+
...envs,
|
|
1640
|
+
" type: stdio",
|
|
1641
|
+
" enabled: true",
|
|
1642
|
+
" timeout: 300",
|
|
1643
|
+
].join("\n");
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function gooseConfigPath() {
|
|
1647
|
+
const platform = process.platform;
|
|
1648
|
+
if (platform === "win32") return path.join(process.env.APPDATA, "Block", "goose", "config", "config.yaml");
|
|
1649
|
+
return path.join(userHome(), ".config", "goose", "config.yaml");
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
async function setupGoose(apiKey, mcpServerUrl) {
|
|
1653
|
+
console.log("\n Configuring Goose...\n");
|
|
1654
|
+
const configPath = gooseConfigPath();
|
|
1655
|
+
console.log(` Config: ${configPath}\n`);
|
|
1656
|
+
|
|
1657
|
+
const niroBlock = buildGooseNiroBlock(apiKey, mcpServerUrl);
|
|
1658
|
+
let existing = "";
|
|
1659
|
+
let existedBefore = false;
|
|
1660
|
+
try {
|
|
1661
|
+
existedBefore = fs.existsSync(configPath);
|
|
1662
|
+
if (existedBefore) existing = fs.readFileSync(configPath, "utf-8");
|
|
1663
|
+
} catch (err) {
|
|
1664
|
+
console.warn(` Could not read ${configPath}: ${err.message}\n`);
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// Matches exactly the " niro:" line install writes (2-space indent, nothing trailing) —
|
|
1668
|
+
// the same pattern uninstallGoose looks for to remove it. A loose `/^\s*niro:/` would also
|
|
1669
|
+
// match an unrelated key named "niro" anywhere in the file and wrongly report "already
|
|
1670
|
+
// installed" without ever writing the real entry.
|
|
1671
|
+
if (/^ {2}niro:\s*$/m.test(existing)) {
|
|
1672
|
+
console.log(" A `niro` extension entry already exists in config.yaml — skipping (edit it by hand if it needs updating).\n");
|
|
1673
|
+
return true;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Bare "extensions:" with nothing else on the line is the only shape we insert into
|
|
1677
|
+
// automatically. Anything else that still looks like a top-level `extensions:` key —
|
|
1678
|
+
// flow-style (`extensions: {}`), a trailing comment, or other content on the same line —
|
|
1679
|
+
// is NOT touched: a regex-based insert there risks producing a duplicate top-level key
|
|
1680
|
+
// (YAML's last-wins/undefined behavior on duplicates could silently drop the user's other
|
|
1681
|
+
// extensions). Fall back to the manual-paste path instead, exactly as documented above.
|
|
1682
|
+
const hasBareExtensionsKey = /^extensions:\s*$/m.test(existing);
|
|
1683
|
+
const hasOtherExtensionsForm = !hasBareExtensionsKey && /^extensions:.*\S.*$/m.test(existing);
|
|
1684
|
+
|
|
1685
|
+
let next = null;
|
|
1686
|
+
if (!existedBefore || existing.trim() === "") {
|
|
1687
|
+
next = `extensions:\n${niroBlock}\n`;
|
|
1688
|
+
} else if (hasBareExtensionsKey) {
|
|
1689
|
+
// Insert right after that line, at the same indent Goose's own docs use, rather than
|
|
1690
|
+
// attempting a full YAML merge with no parser available.
|
|
1691
|
+
next = existing.replace(/^extensions:\s*$/m, `extensions:\n${niroBlock}`);
|
|
1692
|
+
} else if (!hasOtherExtensionsForm) {
|
|
1693
|
+
// No `extensions:` key in any form yet — safe to append a fresh one.
|
|
1694
|
+
next = `${existing.replace(/\n?$/, "\n")}\nextensions:\n${niroBlock}\n`;
|
|
1695
|
+
}
|
|
1696
|
+
// else: hasOtherExtensionsForm — next stays null, falls through to the manual-paste path.
|
|
1697
|
+
|
|
1698
|
+
if (next === null) {
|
|
1699
|
+
console.log(" Your config.yaml has an `extensions:` key in a form this installer doesn't");
|
|
1700
|
+
console.log(" auto-edit (to avoid risking a duplicate key). Add this block under it by hand:\n");
|
|
1701
|
+
console.log(niroBlock);
|
|
1702
|
+
console.log(`\n (full file: ${configPath})\n`);
|
|
1703
|
+
return false;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
try {
|
|
1707
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1708
|
+
fs.writeFileSync(configPath, next);
|
|
1709
|
+
console.log(` MCP server added to Goose config: ${configPath}\n`);
|
|
1710
|
+
console.log(" Restart Goose (or start a new session) so the extension loads.\n");
|
|
1711
|
+
return true;
|
|
1712
|
+
} catch (err) {
|
|
1713
|
+
console.error(` Failed to write config: ${err.message}`);
|
|
1714
|
+
console.error(" Add this manually under a top-level `extensions:` key in config.yaml:\n");
|
|
1715
|
+
console.log(`extensions:\n${niroBlock}`);
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Registers the niro MCP entry in a client's JSON config file. Generalized over the
|
|
1722
|
+
* per-client differences discovered across the 14 supported clients:
|
|
1723
|
+
* - top-level key: "mcpServers" (most clients) vs "mcp" (OpenCode) — client.topLevelKey.
|
|
1724
|
+
* - entry shape: command/args/env (default) vs OpenCode's command array + "environment" —
|
|
1725
|
+
* client.buildEntry(apiKey, mcpServerUrl), falling back to the default shape below.
|
|
1726
|
+
* Cursor's `transport: "stdio"` stays a one-off since it's the only client needing it.
|
|
1727
|
+
*/
|
|
1728
|
+
async function setupJsonConfig(clientId, apiKey, mcpServerUrl) {
|
|
1729
|
+
const client = CLIENTS[clientId];
|
|
1730
|
+
const configPathFn = client.configPath;
|
|
1731
|
+
if (!configPathFn) {
|
|
1732
|
+
console.error(` No config path known for ${client.name}.`);
|
|
1733
|
+
return false;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
const configPath = configPathFn();
|
|
1737
|
+
console.log(`\n Configuring ${client.name}...`);
|
|
1738
|
+
console.log(` Config: ${configPath}\n`);
|
|
1739
|
+
|
|
1740
|
+
// Read existing config or start fresh
|
|
1741
|
+
let config = {};
|
|
1742
|
+
try {
|
|
1743
|
+
const existing = fs.readFileSync(configPath, "utf-8");
|
|
1744
|
+
config = JSON.parse(existing);
|
|
1745
|
+
} catch {
|
|
1746
|
+
// File doesn't exist or invalid — start fresh
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const topLevelKey = client.topLevelKey || "mcpServers";
|
|
1750
|
+
if (!config[topLevelKey]) config[topLevelKey] = {};
|
|
1751
|
+
|
|
1752
|
+
const niroEntry = client.buildEntry
|
|
1753
|
+
? client.buildEntry(apiKey, mcpServerUrl)
|
|
1754
|
+
: {
|
|
1755
|
+
command: "niro",
|
|
1756
|
+
args: ["mcp", "serve"],
|
|
1757
|
+
// The bridge reads NIRO_MCP_SERVER_URL (ai-assistant) — always include it.
|
|
1758
|
+
// Omit NIRO_API_KEY when a cli-token is available; the server reads it from
|
|
1759
|
+
// ~/.niro/credentials.json at runtime.
|
|
1760
|
+
env: apiKey
|
|
1761
|
+
? { NIRO_API_KEY: apiKey, NIRO_MCP_SERVER_URL: mcpServerUrl }
|
|
1762
|
+
: { NIRO_MCP_SERVER_URL: mcpServerUrl },
|
|
1763
|
+
};
|
|
1764
|
+
// Cursor honours explicit stdio transports; harmless for docs / newer Cursor builds.
|
|
1765
|
+
if (clientId === "cursor") {
|
|
1766
|
+
niroEntry.transport = "stdio";
|
|
1767
|
+
}
|
|
1768
|
+
config[topLevelKey].niro = niroEntry;
|
|
1769
|
+
|
|
1770
|
+
try {
|
|
1771
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1772
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1773
|
+
console.log(` MCP server added to ${client.name} config.\n`);
|
|
1774
|
+
if (clientId === "cursor") {
|
|
1775
|
+
ensureCursorPermissionsForNiro();
|
|
1776
|
+
ensureCursorRule();
|
|
1777
|
+
console.log(" Quit and reopen Cursor (full restart) so MCP + permissions reload.\n");
|
|
1778
|
+
}
|
|
1779
|
+
if (clientId === "windsurf") {
|
|
1780
|
+
addAgentsSkill();
|
|
1781
|
+
addAgentsCliSkill();
|
|
1782
|
+
addWindsurfGlobalRule();
|
|
1783
|
+
console.log(" Restart Windsurf so MCP + rules reload.\n");
|
|
1784
|
+
}
|
|
1785
|
+
if (clientId === "opencode") {
|
|
1786
|
+
addOpenCodeMemory();
|
|
1787
|
+
console.log(" Restart OpenCode so the MCP server reloads.\n");
|
|
1788
|
+
}
|
|
1789
|
+
if (clientId === "cline") {
|
|
1790
|
+
addClineRule();
|
|
1791
|
+
console.log(" Reopen Cline's MCP Servers panel (or restart VS Code) to reload.\n");
|
|
1792
|
+
}
|
|
1793
|
+
if (clientId === "kiro") {
|
|
1794
|
+
addKiroSteering();
|
|
1795
|
+
console.log(" Kiro reconnects MCP servers automatically on config save.\n");
|
|
1796
|
+
}
|
|
1797
|
+
if (clientId === "antigravity") {
|
|
1798
|
+
console.log(" Reload via Agent Settings → Customizations → Installed MCP Servers → Refresh.\n");
|
|
1799
|
+
}
|
|
1800
|
+
return true;
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
console.error(` Failed to write config: ${err.message}`);
|
|
1803
|
+
console.error(` Manually add to ${configPath}:\n`);
|
|
1804
|
+
console.log(JSON.stringify({ [topLevelKey]: { niro: config[topLevelKey].niro } }, null, 2));
|
|
1805
|
+
return false;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Clients whose installer shells out via execSync(cmd.join(" ")) — the only ones where
|
|
1811
|
+
* hasShellMetacharacters needs to gate apiKey/mcpServerUrl before dispatch. Every other
|
|
1812
|
+
* client (the generic setupJsonConfig path, plus Goose which writes YAML directly with fs)
|
|
1813
|
+
* never reaches a shell, so a URL like "...?token=abc&env=prod" is perfectly valid for them.
|
|
1814
|
+
*/
|
|
1815
|
+
const CLI_SETUP = {
|
|
1816
|
+
"claude-code": setupClaudeCode,
|
|
1817
|
+
codex: setupCodex,
|
|
1818
|
+
"gemini-cli": setupGeminiCli,
|
|
1819
|
+
"copilot-cli": setupCopilotCli,
|
|
1820
|
+
factory: setupFactory,
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
async function main() {
|
|
1824
|
+
const args = process.argv.slice(2);
|
|
1825
|
+
let clientArg = null;
|
|
1826
|
+
let urlArg = null;
|
|
1827
|
+
for (let i = 0; i < args.length; i++) {
|
|
1828
|
+
if (args[i] === "--client" && args[i + 1]) clientArg = args[i + 1];
|
|
1829
|
+
else if (args[i] === "--url" && args[i + 1]) urlArg = args[++i];
|
|
1830
|
+
}
|
|
1831
|
+
if (urlArg && !/^https?:\/\//i.test(urlArg)) {
|
|
1832
|
+
console.error(`\n Invalid --url: "${urlArg}" — it must start with http:// or https://\n`);
|
|
1833
|
+
process.exit(1);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const rl = readline.createInterface({
|
|
1837
|
+
input: process.stdin,
|
|
1838
|
+
output: process.stdout,
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
printHeader();
|
|
1842
|
+
|
|
1843
|
+
// Credential: prefer a `niro login` session (cli-token, read at runtime from
|
|
1844
|
+
// ~/.niro/credentials.json). Only prompt for an API key when there is no cli-token.
|
|
1845
|
+
let apiKey = null;
|
|
1846
|
+
if (hasCliToken()) {
|
|
1847
|
+
console.log("\n Using your `niro login` session — no API key needed.");
|
|
1848
|
+
console.log(" The MCP server will authenticate with your CLI token from ~/.niro/credentials.json.\n");
|
|
1849
|
+
} else {
|
|
1850
|
+
apiKey = await ask(rl, " Niro API key (from console.niroai.dev > Settings)", resolveApiKey());
|
|
1851
|
+
if (!apiKey) {
|
|
1852
|
+
console.error("\n No credential found. Run `niro login` first (recommended), or paste an API key");
|
|
1853
|
+
console.error(" from console.niroai.dev > Settings > Account.\n");
|
|
1854
|
+
rl.close();
|
|
1855
|
+
process.exit(1);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// The MCP server URL is resolved, not prompted — see resolveMcpServerUrl.
|
|
1860
|
+
// Printing the URL + its source keeps a wrong pick visible and correctable.
|
|
1861
|
+
const { url: mcpServerUrl, source: urlSource, explicit } = resolveMcpServerUrl(urlArg);
|
|
1862
|
+
console.log(` MCP server URL: ${mcpServerUrl} (${urlSource})`);
|
|
1863
|
+
if (!urlArg) {
|
|
1864
|
+
console.log(" Different backend? Re-run with: niro mcp install --url <url>\n");
|
|
1865
|
+
} else {
|
|
1866
|
+
console.log("");
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Persist an EXPLICITLY chosen URL to ~/.niro/config.json so the bridge can
|
|
1870
|
+
// fall back to it and later installs reuse it. The default is never saved
|
|
1871
|
+
// (the bridge has its own default; saving would pin it across releases).
|
|
1872
|
+
if (explicit) {
|
|
1873
|
+
try {
|
|
1874
|
+
config.save({ mcpServerUrl });
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
console.warn(`\n Could not write ${config.FILE}: ${err.message}\n`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// Select client
|
|
1881
|
+
let selectedClient = clientArg;
|
|
1882
|
+
if (!selectedClient) {
|
|
1883
|
+
console.log("");
|
|
1884
|
+
printClients();
|
|
1885
|
+
selectedClient = await ask(rl, " Which client to configure?", "claude-code");
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (!CLIENTS[selectedClient]) {
|
|
1889
|
+
console.error(`\n Unknown client: ${selectedClient}`);
|
|
1890
|
+
printClients();
|
|
1891
|
+
rl.close();
|
|
1892
|
+
process.exit(1);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
rl.close();
|
|
1896
|
+
|
|
1897
|
+
// Configure. The editor MCP env points at the bridge backend (ai-assistant) via mcpServerUrl;
|
|
1898
|
+
// the ai-orchestrator URL is handled separately by the CLI (from credentials.json / NIRO_API_URL).
|
|
1899
|
+
// Only these installers shell out via execSync(cmd.join(" ")) — everyone else (including
|
|
1900
|
+
// goose, which writes YAML directly with fs) never reaches a shell, so gate the
|
|
1901
|
+
// shell-metacharacter guard to just these instead of blocking every client on a risk that
|
|
1902
|
+
// only applies to some (e.g. a query-string URL with "&" is fine for Cursor/VS Code/etc.).
|
|
1903
|
+
if (CLI_SETUP[selectedClient]) {
|
|
1904
|
+
if (hasShellMetacharacters(mcpServerUrl)) {
|
|
1905
|
+
console.error(`\n Refusing to use MCP server URL "${mcpServerUrl}" — it contains spaces or`);
|
|
1906
|
+
console.error(" characters not allowed in a URL. Check --url / NIRO_MCP_SERVER_URL.\n");
|
|
1907
|
+
process.exit(1);
|
|
1908
|
+
}
|
|
1909
|
+
if (apiKey && hasShellMetacharacters(apiKey)) {
|
|
1910
|
+
console.error("\n Refusing to use that API key — it contains spaces or characters that aren't");
|
|
1911
|
+
console.error(" valid in an API key. Copy it again from console.niroai.dev > Settings > Account.\n");
|
|
1912
|
+
process.exit(1);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
// Goose writes YAML directly (no JSON shape, no shell) — its own dispatch branch, separate
|
|
1916
|
+
// from both the shell-based CLI_SETUP map and the generic JSON setupJsonConfig path.
|
|
1917
|
+
let success;
|
|
1918
|
+
if (CLI_SETUP[selectedClient]) {
|
|
1919
|
+
success = await CLI_SETUP[selectedClient](apiKey, mcpServerUrl);
|
|
1920
|
+
} else if (selectedClient === "goose") {
|
|
1921
|
+
success = await setupGoose(apiKey, mcpServerUrl);
|
|
1922
|
+
} else {
|
|
1923
|
+
success = await setupJsonConfig(selectedClient, apiKey, mcpServerUrl);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
if (success) {
|
|
1927
|
+
console.log(" Setup complete! Restart your AI assistant to start using Niro.\n");
|
|
1928
|
+
if (selectedClient !== "claude-code") {
|
|
1929
|
+
console.log(" Tip: drop a `.niro-project` file at the repo root (single line");
|
|
1930
|
+
console.log(" with the project_id) to pin that tree to a Niro project.");
|
|
1931
|
+
console.log(" Without it, Niro runs in multi-project mode and discovers");
|
|
1932
|
+
console.log(" projects via the list_projects tool.\n");
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Only run the interactive wizard when invoked directly (niro mcp install). When
|
|
1938
|
+
// required as a module, expose the skill installers so they can be (re)installed
|
|
1939
|
+
// on their own — without re-running the wizard, which would rewrite MCP config.
|
|
1940
|
+
if (require.main === module) {
|
|
1941
|
+
main().catch((err) => {
|
|
1942
|
+
console.error(`Setup failed: ${err.message}`);
|
|
1943
|
+
process.exit(1);
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
module.exports = {
|
|
1948
|
+
writeManagedSkill,
|
|
1949
|
+
addClaudeCodeSkill,
|
|
1950
|
+
addClaudeCodeCliSkill,
|
|
1951
|
+
addAgentsSkill,
|
|
1952
|
+
addAgentsCliSkill,
|
|
1953
|
+
// Shared with teardown.js so uninstall removes exactly what install wrote.
|
|
1954
|
+
CLIENTS,
|
|
1955
|
+
NIRO_MD_MARKER,
|
|
1956
|
+
NIRO_MD_SECTIONS,
|
|
1957
|
+
isNiroPostCompactHook,
|
|
1958
|
+
isNiroUserPromptHook,
|
|
1959
|
+
isNiroPreToolUseHook,
|
|
1960
|
+
userHome,
|
|
1961
|
+
// Exposed for content regression tests (e.g. the cross-project routing guidance).
|
|
1962
|
+
NIRO_SKILL,
|
|
1963
|
+
NIRO_CLI_SKILL,
|
|
1964
|
+
// Exposed for tests: URL resolution replaced the install-time prompt.
|
|
1965
|
+
resolveMcpServerUrl,
|
|
1966
|
+
SAAS_URL,
|
|
1967
|
+
// Exposed for tests: shell-injection boundary guard, shared by every CLI-registration path.
|
|
1968
|
+
hasShellMetacharacters,
|
|
1969
|
+
// Exposed for tests: Goose's hand-rolled YAML block (no YAML dependency — see its own doc comment).
|
|
1970
|
+
buildGooseNiroBlock,
|
|
1971
|
+
gooseConfigPath,
|
|
1972
|
+
// Exposed for tests: exactly which clients are gated by hasShellMetacharacters (shell-based installers only).
|
|
1973
|
+
CLI_SETUP,
|
|
1974
|
+
// Exposed for tests: exercise the real YAML install path (duplicate-key avoidance, idempotency).
|
|
1975
|
+
setupGoose,
|
|
1976
|
+
};
|