@niroai/niro 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +291 -0
  2. package/bin/niro.js +2 -0
  3. package/package.json +41 -0
  4. package/src/commands/_resolveProject.js +142 -0
  5. package/src/commands/add-project.js +190 -0
  6. package/src/commands/add-repo.js +270 -0
  7. package/src/commands/build.js +118 -0
  8. package/src/commands/delete.js +66 -0
  9. package/src/commands/edit.js +601 -0
  10. package/src/commands/info.js +172 -0
  11. package/src/commands/init.js +1166 -0
  12. package/src/commands/login.js +214 -0
  13. package/src/commands/logout.js +33 -0
  14. package/src/commands/mcp.js +40 -0
  15. package/src/commands/projects.js +76 -0
  16. package/src/commands/release.js +175 -0
  17. package/src/commands/remove.js +95 -0
  18. package/src/commands/status.js +75 -0
  19. package/src/commands/takeover.js +204 -0
  20. package/src/commands/watch.js +498 -0
  21. package/src/commands/whoami.js +74 -0
  22. package/src/index.js +293 -0
  23. package/src/lib/agentApi.js +276 -0
  24. package/src/lib/api.js +230 -0
  25. package/src/lib/browser.js +30 -0
  26. package/src/lib/color.js +46 -0
  27. package/src/lib/config.js +38 -0
  28. package/src/lib/credentials.js +73 -0
  29. package/src/lib/folderSync.js +503 -0
  30. package/src/lib/git.js +190 -0
  31. package/src/lib/moduleExcludeSuggestions.js +57 -0
  32. package/src/lib/niroProjectFile.js +76 -0
  33. package/src/lib/pendingRequests.js +99 -0
  34. package/src/lib/prompt.js +118 -0
  35. package/src/lib/resolveContext.js +108 -0
  36. package/src/lib/secret.js +114 -0
  37. package/src/lib/service.js +221 -0
  38. package/src/lib/spinner.js +109 -0
  39. package/src/lib/watchDaemon.js +93 -0
  40. package/src/lib/watchState.js +217 -0
  41. package/src/mcp/server.js +764 -0
  42. package/src/mcp/setup.js +1976 -0
  43. package/src/mcp/teardown.js +673 -0
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # niro — MCP server + CLI (@niroai/niro)
2
+
3
+ The command-line tool **and** MCP server for [Niro](https://niroai.dev) — the AI code-intelligence platform.
4
+
5
+ One package gives you three things:
6
+
7
+ 1. **`niro` CLI** — onboard repos, build the code graph, and manage projects from the terminal.
8
+ 2. **A Niro MCP server** — lets an AI assistant answer questions about *your* codebase using Niro's graph. 14 clients supported (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, GitHub Copilot CLI, Factory, Goose, OpenCode, Cline, Kiro, Antigravity, Claude Desktop) — see **[docs/connect-your-ai-assistant.md](docs/connect-your-ai-assistant.md)** for the exact command per client.
9
+ 3. **Two agent skills** (installed by `niro mcp install`) so you can drive Niro from chat in plain English instead of typing CLI commands:
10
+ - **`niro-skill`** — answers questions about your code (structure, call chains, blast radius) using the Niro MCP tools.
11
+ - **`niro-cli`** — runs the CLI for you (onboard, status, rebuild, edit env vars, remove) from a conversation.
12
+
13
+ > **Two ways to do everything:** every task below shows the **terminal** command *and* how you'd **ask the agent**. Pick whichever you prefer — they call the same code.
14
+
15
+ > **Working on a feature branch?** Niro indexes one branch per repo, but it follows *your* branch
16
+ > and uncommitted edits through **temporary projects** — see
17
+ > **[docs/working-on-branches.md](docs/working-on-branches.md)** for how that works in plain words.
18
+
19
+ ---
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install -g @niroai/niro
25
+ niro --version
26
+ ```
27
+
28
+ Requires **Node.js 18+**. Background auto-sync of local folders uses [PM2](https://pm2.keymetrics.io/) — `niro init` offers to install it for you (or run `npm install -g pm2` yourself).
29
+
30
+ By default the CLI talks to Niro's cloud (`https://aiorchestrator.niroai.dev`) and the MCP server to `https://aiassistant.niroai.dev`. To point at your own instance, set `NIRO_API_URL`, and pass `--url <url>` to `niro mcp install` (or set `NIRO_MCP_SERVER_URL`).
31
+
32
+ ---
33
+
34
+ ## 60-second setup
35
+
36
+ ```bash
37
+ niro login # opens your browser to approve this device
38
+ cd ~/code/my-service
39
+ niro init # onboard this folder: create project, upload, build the graph
40
+ niro mcp install # connect one AI assistant — pick it at the prompt, or pass --client
41
+ ```
42
+
43
+ `niro mcp install` configures **one** assistant per run — pick it at the prompt, or pass `--client <id>`. 14 clients supported; see **[docs/connect-your-ai-assistant.md](docs/connect-your-ai-assistant.md)** for the full list and the exact command for each. Run it once for each assistant you use. The MCP server URL is picked automatically (own instance? pass `--url <url>`). Restart your AI assistant once after, and you're done — ask it about your code, or ask it to run Niro tasks for you.
44
+
45
+ ---
46
+
47
+ ## Scenario 1 — Log in
48
+
49
+ `niro login` opens a browser, you approve the device, and a long-lived credential is stored at `~/.niro/credentials.json` (mode `0600`). Short-lived tokens refresh automatically.
50
+
51
+ **Terminal**
52
+ ```bash
53
+ niro login # browser device flow (default)
54
+ niro whoami # who am I logged in as?
55
+ niro logout # revoke + remove local credentials
56
+ ```
57
+
58
+ **Ask the agent**
59
+ > "Am I logged in to Niro?" — the agent runs `niro whoami`. If you're not, it will **ask you to run `niro login` yourself** in your terminal (it can't drive a browser sign-in or handle your credentials).
60
+
61
+ ---
62
+
63
+ ## Scenario 2 — Onboard a folder you're working in
64
+
65
+ `niro init` does the whole thing end-to-end: detect the repo, **upload its source + config files**, create a Niro project, scan for environment variables, build the graph, and (optionally) keep it synced.
66
+
67
+ **Terminal**
68
+ ```bash
69
+ cd ~/code/my-service
70
+ niro init # onboard the current folder
71
+ niro init ./api # onboard a specific path
72
+ niro init --defaults # accept discovered defaults, don't prompt
73
+ niro init -X '**/test/**' -F '**/*.spec.ts' # exclude modules / files
74
+ ```
75
+
76
+ During `init` you'll be asked, **per environment variable**, which value to use — and when a variable is defined in more than one config file you pick which file drives it (see Scenario 6).
77
+
78
+ **Ask the agent**
79
+ > "Onboard this folder into Niro." The `niro-cli` skill checks auth, runs `niro info` to see if it's already indexed, then runs `niro init` for you and walks you through the env-var values in chat.
80
+
81
+ **Note for cloud:** Niro indexes your folder by **uploading its files** (it can't read your laptop's disk). Source and committed config files (`package.json`, `pom.xml`, `application.yml`, …) are uploaded; anything in your `.gitignore` (like a real `.env`) is **never** uploaded.
82
+
83
+ ---
84
+
85
+ ## Scenario 3 — "What does Niro know about this folder?"
86
+
87
+ **Terminal**
88
+ ```bash
89
+ niro info # repo URL, branch, which project(s) map here, index status
90
+ niro info --json # machine-readable
91
+ niro status # live build status for the pinned project
92
+ niro status my-service --json
93
+ ```
94
+
95
+ If a folder maps to **more than one** project, `info` lists them and (in the terminal) `build`/`edit`/`remove` ask you to pick one.
96
+
97
+ **Ask the agent**
98
+ > "What does Niro know about this folder?" / "Is this repo indexed?" — the agent runs `niro info --json` and explains it.
99
+
100
+ ---
101
+
102
+ ## Scenario 4 — See all your projects
103
+
104
+ **Terminal**
105
+ ```bash
106
+ niro projects # account-wide list (alias: `niro ls`)
107
+ niro projects --json
108
+ ```
109
+
110
+ **Ask the agent**
111
+ > "List my Niro projects." — runs `niro projects --json`.
112
+
113
+ ---
114
+
115
+ ## Scenario 5 — Rebuild the index after changes
116
+
117
+ **Terminal**
118
+ ```bash
119
+ niro build # full rebuild of the project pinned to this folder
120
+ niro build my-service # by alias or id
121
+ niro build --no-full-rebuild # incremental
122
+ niro build --no-watch # trigger and return immediately
123
+ ```
124
+
125
+ **Ask the agent**
126
+ > "Rebuild the Niro index for this project." — runs `niro build`.
127
+
128
+ *(If you set up auto-sync in Scenario 8, you rarely need to rebuild by hand.)*
129
+
130
+ ---
131
+
132
+ ## Scenario 6 — Manage environment variables (and pick which file drives each)
133
+
134
+ Niro discovers env-var **names** from your code and their **default values** from committed config files. When the same variable appears in several files (e.g. `application.yml` vs `application-prod.yml`), you choose which file's value to use — the same as the web UI.
135
+
136
+ **Terminal**
137
+ ```bash
138
+ niro edit --list-env # show vars, which file drives each, "appears in N files"
139
+ niro edit --list-env --json # machine-readable (secret values masked)
140
+ niro edit --set-env PORT=9000 # set a value directly (never printed back)
141
+ niro edit --set-env-from PORT=application-prod.yml # adopt that file's value for PORT
142
+ niro edit --rescan-env # re-scan after adding config files
143
+ ```
144
+
145
+ Secret values are always masked (`****`) — whether the variable *name* looks secret (`DB_PASSWORD`) **or** its *value* embeds credentials (`DATABASE_URL=postgres://user:pass@host`). You pick those by file name; the value is never shown.
146
+
147
+ **Ask the agent**
148
+ > "Set PORT from application-prod.yml" or "What env vars does this project have?" — the agent runs `niro edit --list-env --json`, shows you the choices, and applies your pick with `--set-env-from`. It chooses **by file name** and never handles raw secret values.
149
+
150
+ ---
151
+
152
+ ## Scenario 7 — Rename a project, or add/remove a repo
153
+
154
+ **Terminal**
155
+ ```bash
156
+ niro edit --alias new-name # rename the project
157
+ niro edit --add-repo <gitId> # map a registered repo into the project
158
+ niro edit --remove-repo <gitId> # unmap a repo (non-destructive)
159
+ niro edit --project my-service --alias renamed # target a project explicitly
160
+ ```
161
+
162
+ **Ask the agent**
163
+ > "Rename this Niro project to `payments-api`." / "Remove repo X from this project."
164
+
165
+ ---
166
+
167
+ ## Scenario 8 — Keep a local folder auto-synced
168
+
169
+ `niro init` and `niro add repo` install a background daemon (via PM2) that re-uploads your changes and keeps the graph fresh — honoring `.gitignore` so secrets never leave your machine.
170
+
171
+ **Terminal**
172
+ ```bash
173
+ niro service install # install + start the niro-watch daemon (auto-starts on login)
174
+ niro service status
175
+ niro service uninstall
176
+ niro watch # run a watch in the foreground (debugging / no PM2)
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Scenario 9 — Remove a project or detach a repo
182
+
183
+ **Terminal**
184
+ ```bash
185
+ niro remove --unmap-repo <gitId> # detach one repo (non-destructive)
186
+ niro remove --project --confirm-name my-service # delete the WHOLE project (guarded)
187
+ ```
188
+
189
+ `--project` is destructive, so it **requires** `--confirm-name` to exactly match the project's alias or id.
190
+
191
+ **Ask the agent**
192
+ > "Remove the Niro project `my-service`." — the agent confirms with you, then runs `niro remove --project --confirm-name my-service`.
193
+
194
+ ---
195
+
196
+ ## Scenario 10 — Ask your AI assistant about your code
197
+
198
+ After `niro mcp install` (and a restart of your assistant), the `niro-skill` routes code questions through Niro's graph instead of grep:
199
+
200
+ > "What calls `chargeCard()`?"
201
+ > "What breaks if I change the `Invoice` schema?"
202
+ > "Where is the auth middleware wired up?"
203
+ > "Is there existing code that parses webhooks before I write a new one?"
204
+
205
+ This works in any folder that resolves to a Niro project (via its git remote or a `.niro-project` pin).
206
+
207
+ **Where does the MCP point?**
208
+ > "Which backend is my Niro MCP connected to?" — the `niro-cli` skill inspects your editor's MCP config and reports the URL + connection status.
209
+
210
+ ---
211
+
212
+ ## Scenario 11 — Disconnect an AI assistant (uninstall the MCP)
213
+
214
+ `niro mcp uninstall` is the exact inverse of `niro mcp install`: it removes the MCP server registration and everything install added for that editor — the niro hooks in `~/.claude/settings.json`, the `## Niro MCP` section in `CLAUDE.md` / `AGENTS.md`, the generated `niro-skill` / `niro-cli` skills, and Cursor's allowlist entry + project rule. It only touches niro's own entries; your other hooks, MCP servers, and notes are left intact.
215
+
216
+ **Terminal**
217
+ ```bash
218
+ niro mcp uninstall # interactive: pick client(s) (or "all")
219
+ niro mcp uninstall --client claude-code # remove from one editor, no prompt
220
+ niro mcp uninstall --client claude-code,codex # remove from several in one go
221
+ niro mcp uninstall --all -y # remove from every editor, skip the confirm
222
+ ```
223
+
224
+ It does **not** sign you out — your `niro login` session in `~/.niro/credentials.json` is kept (run `niro logout` to clear it), and the `niro` command itself stays installed (remove it with `npm rm -g @niroai/niro`). The shared `~/.agents/skills` (used by both Codex and Windsurf) is only deleted once the last of those two editors is disconnected, so uninstalling one never breaks the other.
225
+
226
+ **Ask the agent**
227
+ > "Disconnect Niro from Claude Code." / "Uninstall the Niro MCP from all my editors."
228
+
229
+ ---
230
+
231
+ ## Command reference
232
+
233
+ | Command | What it does |
234
+ |---|---|
235
+ | `niro login` / `logout` / `whoami` | Authenticate / sign out / show current account |
236
+ | `niro init [path]` | Onboard a folder end-to-end (upload, project, env scan, build, watch) |
237
+ | `niro info [path]` | What Niro knows about this folder (repo, branch, project(s), status) |
238
+ | `niro projects` (`niro ls`) | List all projects in your account |
239
+ | `niro status [project]` | Live build status for a project |
240
+ | `niro build [project]` | Trigger a rebuild and stream progress |
241
+ | `niro edit …` | Rename, map/unmap repos, manage env vars (incl. `--set-env-from`) |
242
+ | `niro remove …` | Delete a project (`--project`, guarded) or detach a repo (`--unmap-repo`) |
243
+ | `niro add repo [path\|url]` | Register a local folder or remote git URL |
244
+ | `niro add project` | Create a project from registered repos |
245
+ | `niro watch [path]` / `niro service …` | Foreground watch / manage the auto-sync daemon |
246
+ | `niro mcp install` | Connect an AI assistant + install the agent skills (one per run; `--client <id>` to skip the prompt, `--url <url>` for your own backend) |
247
+ | `niro mcp uninstall` | Disconnect an AI assistant — reverses `mcp install` (`--client <id>` / `--all`, `-y` to skip confirm) |
248
+ | `niro delete [project]` | Older whole-project delete (prefer `niro remove --project`) |
249
+
250
+ Add `--json` to `whoami`, `info`, `status`, `projects`, `build`, and `edit --list-env` for machine-readable output. Add `--help` to any command for its full flags.
251
+
252
+ ---
253
+
254
+ ## Config & local state
255
+
256
+ | Location | Purpose |
257
+ |---|---|
258
+ | `~/.niro/credentials.json` | Auth credential + token (mode `0600`) |
259
+ | `~/.niro/config.json` | `apiUrl` / `mcpServerUrl` saved during setup |
260
+ | `~/.niro/projects.json` | Per-folder sync state (gitId, patterns, file hashes) |
261
+ | `.niro-project` (in a repo) | Pins that folder to a Niro project (single line: project id or alias) |
262
+
263
+ | Environment variable | Purpose | Default |
264
+ |---|---|---|
265
+ | `NIRO_API_URL` | CLI → ai-orchestrator base URL | `https://aiorchestrator.niroai.dev` |
266
+ | `NIRO_MCP_SERVER_URL` | MCP server (ai-assistant) base URL | `https://aiassistant.niroai.dev` |
267
+ | `NIRO_API_KEY` | Read by the **MCP server** (e.g. headless `niro mcp serve`) — not a CLI-command auth path; for CI use `niro login --api-key` | — |
268
+ | `NIRO_UPLOAD_CONCURRENCY` / `NIRO_UPLOAD_TIMEOUT_MS` / `NIRO_UPLOAD_MAX_TOTAL_BYTES` / `NIRO_UPLOAD_MAX_FILES` | Tune the initial upload | 4 / 60000 / 1 GiB / 50000 |
269
+
270
+ ---
271
+
272
+ ## Security notes
273
+
274
+ - **Secrets stay on your machine.** Uploads honor `.gitignore` (and `.git/info/exclude` / global excludes), so a gitignored `.env` is never sent. Raw `.env`/`*.properties` files aren't in the upload set; committed config files (`application.yml`, `.env.example`, …) are, because they hold *behavioral* defaults, not secrets.
275
+ - **Secret values are never printed.** `--set-env` / `--set-env-from` never echo a value, and `--list-env` masks any value that looks like a secret — by key name *or* by content (connection strings, keys).
276
+ - **The agent never handles raw secret values.** It picks env sources *by file name*; you type any real secret yourself.
277
+
278
+ ---
279
+
280
+ ## Troubleshooting
281
+
282
+ - **The agent doesn't run Niro commands** — restart your AI assistant after `niro mcp install` (skills load at startup). Re-running `niro mcp install` refreshes the skills if they've been updated.
283
+ - **`niro` not found** — ensure your global npm bin is on `PATH` (`npm bin -g`).
284
+ - **A folder maps to multiple projects** — pass `--project <alias|id>` (or pick one when prompted).
285
+ - **Auto-sync isn't running** — install PM2 (`npm install -g pm2`) then `niro service install`.
286
+
287
+ ---
288
+
289
+ ## License
290
+
291
+ MIT
package/bin/niro.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../src/index.js");
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@niroai/niro",
3
+ "version": "0.3.0",
4
+ "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
+ "keywords": [
6
+ "niro",
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "code-intelligence",
10
+ "code-graph",
11
+ "cli",
12
+ "claude",
13
+ "claude-code",
14
+ "ai",
15
+ "developer-tools"
16
+ ],
17
+ "homepage": "https://niroai.dev",
18
+ "bin": {
19
+ "niro": "bin/niro.js",
20
+ "niro-mcp-server": "src/mcp/server.js"
21
+ },
22
+ "main": "src/index.js",
23
+ "scripts": {
24
+ "test": "node --test"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "commander": "^12.1.0"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Resolve a project ID for status/build/delete commands.
3
+ *
4
+ * Precedence:
5
+ * 1. Explicit arg. The arg may be:
6
+ * - a project_id (UUID-shaped) -> used directly (unchanged behavior);
7
+ * - an alias or git URL -> resolved via GET /api/projects/resolve.
8
+ * If an alias/URL maps to MULTIPLE projects, a clear ambiguity error is
9
+ * thrown listing the candidates (we never silently pick the first).
10
+ * 2. Walk up from cwd looking for .niro-project (same logic the MCP bridge uses).
11
+ */
12
+ const api = require("../lib/api");
13
+ const prompt = require("../lib/prompt");
14
+ const niroProjectFile = require("../lib/niroProjectFile");
15
+ const { normalizeProjects, resolveContext } = require("../lib/resolveContext");
16
+
17
+ // A canonical project_id is a UUID. Anything that doesn't look like one and
18
+ // isn't a path is treated as an alias/URL to resolve via the server.
19
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+
21
+ function looksLikeProjectId(value) {
22
+ return UUID_RE.test(String(value).trim());
23
+ }
24
+
25
+ function looksLikeUrl(value) {
26
+ return /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)/i.test(value) || /\.git$/i.test(value);
27
+ }
28
+
29
+ function readNiroProjectFile(startDir) {
30
+ // Format + walk-up live in lib/niroProjectFile (shared with the MCP bridge).
31
+ const found = niroProjectFile.read(startDir);
32
+ return found ? found.id : null;
33
+ }
34
+
35
+ /**
36
+ * A repo (or alias/URL) can map to more than one project. Let an interactive user
37
+ * pick one; in a non-TTY (an agent or a pipe) throw a clear EAMBIGUOUS error that
38
+ * lists the candidates instead of blocking on a prompt — the agent surfaces them
39
+ * (e.g. `niro info --json` reports ambiguous + the list) and re-runs with
40
+ * --project. The candidate list always comes from the account-scoped resolve
41
+ * endpoint, so it only ever contains the caller's own account's projects.
42
+ */
43
+ async function chooseAmongProjects(projects, label) {
44
+ const numbered = projects
45
+ .map((p, i) => ` [${i + 1}] ${p.alias ? `${p.alias} ` : ""}(${p.project_id})`)
46
+ .join("\n");
47
+ if (prompt.isNonInteractive()) {
48
+ const e = new Error(`${label} — specify the project explicitly (--project / --target):\n${numbered}`);
49
+ e.code = "EAMBIGUOUS";
50
+ throw e;
51
+ }
52
+ process.stderr.write(`${label}:\n${numbered}\n`);
53
+ const answer = await prompt.ask(`Choose a project [1-${projects.length}]`);
54
+ const idx = Number.parseInt(String(answer).trim(), 10) - 1;
55
+ if (!Number.isInteger(idx) || idx < 0 || idx >= projects.length) {
56
+ const e = new Error("No valid project selected — re-run and pick a listed number, or pass --project.");
57
+ e.code = "EAMBIGUOUS";
58
+ throw e;
59
+ }
60
+ return projects[idx].project_id;
61
+ }
62
+
63
+ /**
64
+ * Resolve an explicit alias or git URL to a single project_id via the server.
65
+ * Throws ENOPROJECT when nothing matches; when more than one project matches,
66
+ * an interactive user is asked to choose, else an ambiguity error is thrown.
67
+ */
68
+ async function resolveAliasOrUrl(value, opts = {}) {
69
+ const query = looksLikeUrl(value) ? { git_url: value } : { alias: value };
70
+ let data;
71
+ try {
72
+ data = await api.get("/api/projects/resolve", { query, apiUrl: opts.apiUrl });
73
+ } catch (err) {
74
+ const e = new Error(`Could not resolve "${value}": ${err && err.message ? err.message : err}`);
75
+ e.code = "ENOPROJECT";
76
+ throw e;
77
+ }
78
+
79
+ const projects = normalizeProjects(data);
80
+ if (projects.length === 0) {
81
+ const e = new Error(
82
+ `No project found matching "${value}". Pass an explicit project_id or run \`niro init\` first.`
83
+ );
84
+ e.code = "ENOPROJECT";
85
+ throw e;
86
+ }
87
+ if (projects.length > 1) {
88
+ return chooseAmongProjects(projects, `"${value}" matches multiple projects`);
89
+ }
90
+ return projects[0].project_id;
91
+ }
92
+
93
+ /**
94
+ * Resolve the current working directory to a single project_id via git, the same
95
+ * way `niro info` does (resolveContext -> GET /api/projects/resolve?git_url=...).
96
+ * - exactly 1 project -> its project_id
97
+ * - more than 1 -> EAMBIGUOUS listing the candidates
98
+ * - 0 -> ENOPROJECT with a reason derived from ctx.state
99
+ */
100
+ async function resolveProjectFromGit(opts = {}) {
101
+ const ctx = await resolveContext(process.cwd(), opts);
102
+ const projects = ctx.projects || [];
103
+ if (projects.length === 1) return projects[0].project_id;
104
+ if (projects.length > 1) {
105
+ return chooseAmongProjects(projects, "This repo belongs to multiple Niro projects");
106
+ }
107
+ // No project resolved — explain why based on how far git resolution got.
108
+ const reasons = {
109
+ not_a_git_repo: "this directory is not inside a git repository",
110
+ no_origin: "this git repository has no 'origin' remote",
111
+ error: `project resolution failed${ctx.error ? `: ${ctx.error}` : ""}`,
112
+ unresolved: "no Niro project maps this repository in your account",
113
+ };
114
+ const reason = reasons[ctx.state] || "no Niro project could be resolved";
115
+ const e = new Error(
116
+ `No project_id provided and ${reason}. Pass the project explicitly or run \`niro init\` first.`
117
+ );
118
+ e.code = "ENOPROJECT";
119
+ throw e;
120
+ }
121
+
122
+ async function resolveProjectId(explicit, opts = {}) {
123
+ if (explicit) {
124
+ // A UUID is used directly — unchanged behavior. Anything else is an
125
+ // alias/URL that we resolve via the server.
126
+ if (looksLikeProjectId(explicit)) return explicit;
127
+ return resolveAliasOrUrl(explicit, opts);
128
+ }
129
+ // No explicit arg: prefer a .niro-project pin (walk up from cwd).
130
+ const fromFile = readNiroProjectFile(process.cwd());
131
+ if (fromFile) {
132
+ // A pin that is already a project_id is used directly. A pin holding an
133
+ // alias or git URL is resolved via the server (so an alias pin like "niro"
134
+ // works instead of being sent raw as a project_id and 403'ing).
135
+ if (looksLikeProjectId(fromFile)) return fromFile;
136
+ return resolveAliasOrUrl(fromFile, opts);
137
+ }
138
+ // No pin: fall back to git-based resolution, the same as `niro info`.
139
+ return resolveProjectFromGit(opts);
140
+ }
141
+
142
+ module.exports = { resolveProjectId, readNiroProjectFile, resolveAliasOrUrl, looksLikeProjectId };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `niro add project` — create a project and map repos to it.
3
+ *
4
+ * Shows a numbered list of all registered repos (local and remote).
5
+ * User selects which to include. Project is created and graph build triggered.
6
+ */
7
+ const path = require("path");
8
+ const api = require("../lib/api");
9
+ const credentials = require("../lib/credentials");
10
+ const prompt = require("../lib/prompt");
11
+ const color = require("../lib/color");
12
+ const watchState = require("../lib/watchState");
13
+ const niroProjectFile = require("../lib/niroProjectFile");
14
+ const { login } = require("./login");
15
+ const { watchStatus } = require("./build");
16
+
17
+ async function addProject(opts = {}) {
18
+ let creds = credentials.load();
19
+ if (!creds || !creds.token) {
20
+ console.log("Not logged in — starting login flow.");
21
+ await login({ apiUrl: opts.apiUrl });
22
+ creds = credentials.load();
23
+ if (!creds || !creds.token) return;
24
+ }
25
+
26
+ // Fetch all repos from server
27
+ let allRepos = [];
28
+ try {
29
+ allRepos = await api.get("/api/git/all");
30
+ } catch (err) {
31
+ console.error(color.red(`Failed to fetch repos: ${err.message}`));
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ if (!Array.isArray(allRepos) || allRepos.length === 0) {
37
+ console.log(color.yellow("No repos registered yet. Run `niro add repo` first."));
38
+ return;
39
+ }
40
+
41
+ // Enrich with local sync state for display (current backend only — the server
42
+ // repos above are this backend's, so foreign-backend local entries don't apply).
43
+ const localState = watchState.allLocal(api.currentBaseUrl());
44
+ const localByGitId = {};
45
+ for (const entry of localState) {
46
+ localByGitId[entry.gitId] = entry;
47
+ }
48
+
49
+ console.log(`\n ${color.bold("Available repos:")}`);
50
+ allRepos.forEach((repo, i) => {
51
+ const gitId = repo.id || repo.gitId;
52
+ const url = repo.repository_url || repo.repositoryUrl || "";
53
+ const sourceType = repo.source_type || repo.sourceType || "";
54
+ const isSynced = sourceType === "AGENT_UPLOAD";
55
+ const isRemote = repo.remote_repository || repo.remoteRepository;
56
+
57
+ const local = localByGitId[gitId];
58
+
59
+ // Label: "synced" for CLI-uploaded, "remote" for cloned from git host, "local" for server-side path
60
+ const label = isSynced
61
+ ? color.green("synced")
62
+ : isRemote
63
+ ? color.dim("remote")
64
+ : color.cyan("local");
65
+
66
+ // Name resolution priority:
67
+ // synced repo → local folder path from ~/.niro/projects.json (what the user knows)
68
+ // otherwise → displayName → URL → basename of server projectDir → gitId last resort
69
+ const serverDir = repo.project_dir || repo.projectDir || "";
70
+ const displayName =
71
+ (local && local.folderPath)
72
+ ? local.folderPath
73
+ : (repo.display_name || repo.displayName || url ||
74
+ (serverDir ? path.basename(serverDir) : null) ||
75
+ color.dim(`id:${gitId}`));
76
+
77
+ const syncInfo = local && local.lastSyncedAt
78
+ ? color.dim(" last synced " + timeSince(local.lastSyncedAt))
79
+ : "";
80
+
81
+ console.log(` [${i + 1}] ${color.bold(displayName)} ${label}${syncInfo}`);
82
+ });
83
+ console.log(` [${allRepos.length + 1}] Register a new repo first (niro add repo)`);
84
+
85
+ const selectionRaw = await prompt.ask(
86
+ `\n Select repos to include (e.g. 1,3)`,
87
+ ""
88
+ );
89
+ if (!selectionRaw.trim()) {
90
+ console.log("No selection made.");
91
+ return;
92
+ }
93
+
94
+ const indices = selectionRaw.split(",")
95
+ .map(s => parseInt(s.trim(), 10) - 1)
96
+ .filter(i => i >= 0 && i < allRepos.length);
97
+
98
+ if (indices.length === 0) {
99
+ console.log(color.yellow("No valid repos selected."));
100
+ return;
101
+ }
102
+
103
+ const selectedRepos = indices.map(i => allRepos[i]);
104
+
105
+ // Project name
106
+ const defaultName = selectedRepos[0]
107
+ ? (selectedRepos[0].display_name || selectedRepos[0].displayName ||
108
+ deriveNameFromUrl(selectedRepos[0].repository_url || selectedRepos[0].repositoryUrl))
109
+ : "my-project";
110
+
111
+ let projectId;
112
+ while (true) {
113
+ const candidate = await prompt.ask(" Project name", defaultName);
114
+ if (!candidate) { console.error(color.red("Project name is required.")); return; }
115
+ if (await projectNameTaken(candidate)) {
116
+ console.log(color.yellow(` "${candidate}" already exists — pick another.`));
117
+ } else {
118
+ projectId = candidate;
119
+ break;
120
+ }
121
+ }
122
+
123
+ // Build gitIdToMapping
124
+ const gitIdToMapping = {};
125
+ for (const repo of selectedRepos) {
126
+ gitIdToMapping[repo.id || repo.gitId] = {};
127
+ }
128
+
129
+ console.log(`\n Creating project "${color.bold(projectId)}"...`);
130
+ const project = await api.post("/api/projects", {
131
+ project_id: projectId,
132
+ git_id_to_mapping: gitIdToMapping,
133
+ });
134
+
135
+ // Update local state with projectId for each local repo
136
+ for (const repo of selectedRepos) {
137
+ const gitId = repo.id || repo.gitId;
138
+ const local = localByGitId[gitId];
139
+ if (local) {
140
+ watchState.update(local.folderPath, { projectId });
141
+ }
142
+ }
143
+
144
+ console.log(` ${color.green("✓")} Project created: ${color.bold(projectId)}`);
145
+
146
+ const shouldBuild = await prompt.confirm(" Trigger a full index build now?", true);
147
+ if (shouldBuild) {
148
+ console.log(` Triggering build...`);
149
+ await api.post(
150
+ `/api/graph/${encodeURIComponent(projectId)}/build`,
151
+ {},
152
+ { query: { force_full_rebuild: "true" } }
153
+ );
154
+ if (opts.watch !== false) {
155
+ await watchStatus(projectId);
156
+ } else {
157
+ console.log(` Build triggered. Run ${color.cyan("niro status")} to track progress.`);
158
+ }
159
+ }
160
+
161
+ console.log(`\n${color.green("✓ Done.")} Run ${color.cyan("niro mcp install")} to configure Claude Code / Cursor / Windsurf.`);
162
+ }
163
+
164
+ async function projectNameTaken(projectId) {
165
+ try {
166
+ await api.get(`/api/projects/${encodeURIComponent(projectId)}`);
167
+ return true;
168
+ } catch (err) {
169
+ const body = err && err.body;
170
+ const msg = (body && (body.error || body.message)) || err.message || "";
171
+ if (/not found/i.test(String(msg))) return false;
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ function deriveNameFromUrl(url) {
177
+ if (!url) return "my-project";
178
+ const m = url.match(/([^/:]+?)(?:\.git)?$/);
179
+ return m ? m[1] : "my-project";
180
+ }
181
+
182
+ function timeSince(isoString) {
183
+ if (!isoString) return "";
184
+ const diff = (Date.now() - new Date(isoString).getTime()) / 1000;
185
+ if (diff < 60) return `${Math.round(diff)}s ago`;
186
+ if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
187
+ return `${Math.round(diff / 3600)}h ago`;
188
+ }
189
+
190
+ module.exports = { addProject };