@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/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
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 };
|