@niroai/niro 0.4.0 → 0.6.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/CHANGELOG.md +104 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/add-repo.js +40 -13
- package/src/commands/info.js +70 -0
- package/src/commands/init.js +41 -3
- package/src/commands/mcp.js +29 -1
- package/src/commands/takeover.js +42 -5
- package/src/commands/watch.js +219 -17
- package/src/index.js +49 -15
- package/src/lib/onboardTarget.js +258 -0
- package/src/lib/receiptStore.js +85 -0
- package/src/lib/tempProject.js +37 -0
- package/src/lib/watchState.js +30 -1
- package/src/mcp/server.js +56 -1
- package/src/mcp/setup.js +251 -26
- package/src/mcp/teardown.js +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@niroai/niro`. Dates are release dates.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 0.6.0 — 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Upgrading
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @niroai/niro@latest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
If you are coming from 0.3.x you also still need `niro mcp install` once per assistant — see
|
|
14
|
+
the 0.5.0 notes below. Upgrading from 0.5.0 needs nothing extra.
|
|
15
|
+
|
|
16
|
+
### Changed — read this before upgrading
|
|
17
|
+
|
|
18
|
+
- **`--api-url` now applies to `niro login` only.** Every other command refuses it and exits 1.
|
|
19
|
+
|
|
20
|
+
It was a global flag, but only half of the code honoured it. Ordinary API calls took it,
|
|
21
|
+
while the file-upload path built its own address from your stored credentials and never saw
|
|
22
|
+
it. So `niro init --api-url http://localhost:8095` while logged in to another backend listed
|
|
23
|
+
projects from localhost and uploaded your source to the backend you were logged in to. One
|
|
24
|
+
command, two servers, no warning.
|
|
25
|
+
|
|
26
|
+
The backend is a property of a session, not of a single command: your credentials, tokens,
|
|
27
|
+
repo ids and sync records are all scoped to one backend, so a per-command override could
|
|
28
|
+
only ever mix them. Choose it once instead:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
niro login --api-url <url> # log in to that backend
|
|
32
|
+
NIRO_API_URL=<url> niro <command> # or set it for every command
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`NIRO_API_URL` and the `apiUrl` config setting still work everywhere, because both feed the
|
|
36
|
+
same resolution the uploader uses. **If you script `--api-url` on any command other than
|
|
37
|
+
`login`, that script now fails with a message** telling it which of the two forms to use.
|
|
38
|
+
|
|
39
|
+
- **`niro init` and `niro add repo` now refuse a folder that is not a project.** Onboarding a
|
|
40
|
+
folder used to work anywhere, including your home folder, which uploaded source from every
|
|
41
|
+
project on the machine, pinned `~/.niro-project` and started a watcher over your whole home
|
|
42
|
+
directory. Two rules now apply before anything is uploaded:
|
|
43
|
+
|
|
44
|
+
- Your home folder, the filesystem root, a parent of your home folder and the system folders
|
|
45
|
+
are refused outright. `--force` does **not** override this.
|
|
46
|
+
- Any other folder must look like a project: a build file (`package.json`, `pom.xml`, `go.mod`,
|
|
47
|
+
`*.csproj`, and the rest of the set the indexer already understands) at the folder itself or
|
|
48
|
+
just below it. A monorepo root whose build files sit one or two levels down is fine.
|
|
49
|
+
|
|
50
|
+
**If you script either command**, a folder with no build file now exits `1` where it used to
|
|
51
|
+
onboard. Pass `--force` for that case. `niro add repo` gains `--force` for this; on `niro init`
|
|
52
|
+
the existing `--force` now covers it too.
|
|
53
|
+
|
|
54
|
+
A folder this backend already has a repo for is exempt from the build-file rule, so an existing
|
|
55
|
+
sync keeps working. The home and root refusal still applies to it.
|
|
56
|
+
|
|
57
|
+
The check runs before the login and before any server call, so a refused folder leaves nothing
|
|
58
|
+
behind: no registered repo, no orphan project, no pin file, no watcher.
|
|
59
|
+
|
|
60
|
+
- **`niro init` now notices nested repos deeper in the tree.** The "this folder contains repos
|
|
61
|
+
already indexed in Niro" refusal only looked two levels down, so it never fired for the common
|
|
62
|
+
`~/projects/<host>/<org>/<repo>` layout. It now looks four.
|
|
63
|
+
|
|
64
|
+
## 0.5.0 — 2026-08-06
|
|
6
65
|
|
|
7
66
|
### Upgrading from 0.3.x — one manual step
|
|
8
67
|
|
|
@@ -18,8 +77,52 @@ startup.
|
|
|
18
77
|
|
|
19
78
|
Nothing else is required.
|
|
20
79
|
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **You can now see what Niro did, without asking.** At the end of a task you get one line:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
[NIRO] Mapped 29 callers across 8 files that a grep would have missed · 3 calls instead of ~14 searches
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The call count is measured by the server. The number of searches it stands in for, and the
|
|
89
|
+
description, are the assistant's own account of its own work, so that number always carries a `~`
|
|
90
|
+
to mark it as an estimate. We would rather you trusted a smaller number than doubted a bigger one.
|
|
91
|
+
|
|
92
|
+
On a turn where Niro found nothing useful, the description is dropped rather than invented, and
|
|
93
|
+
you see the bare call count.
|
|
94
|
+
|
|
95
|
+
- **It also tells you when Niro went unused.** On a turn where the assistant searched your code
|
|
96
|
+
without asking Niro, you get `[NIRO] Not consulted: this turn used grep and file reads instead`.
|
|
97
|
+
Until now a skipped turn looked exactly like Niro not being installed, which meant every one of
|
|
98
|
+
our failures was invisible. Nothing is printed after a turn that did no code work.
|
|
99
|
+
|
|
100
|
+
- **On by default, including on upgrade.** Turn it off with `niro mcp receipt --off` and back on
|
|
101
|
+
with `--on`; run it with no flags to see the current state. The setting is per machine.
|
|
102
|
+
|
|
103
|
+
Works the same in all twelve supported assistants: the line travels back inside the
|
|
104
|
+
`mark_task_complete` tool result and your assistant prints it, wrapped in backticks so it renders
|
|
105
|
+
as code rather than as more of its own prose. If the assistant never calls `mark_task_complete`,
|
|
106
|
+
no line appears.
|
|
107
|
+
|
|
21
108
|
### Fixed
|
|
22
109
|
|
|
110
|
+
- **Codex no longer asks permission for every Niro tool call.** Two causes, both fixed by re-running
|
|
111
|
+
`niro mcp install`:
|
|
112
|
+
|
|
113
|
+
The approval mode was set to `"auto"`, which asks Codex to decide for itself — and it decides to
|
|
114
|
+
ask. It is now `"writes"`: prompt only for tools that write, and Niro writes nothing. An existing
|
|
115
|
+
`"auto"` is migrated; a value you chose yourself is left alone.
|
|
116
|
+
|
|
117
|
+
Codex also records a per-tool override whenever you answer a prompt with "approve once" rather than
|
|
118
|
+
"always allow", and a per-tool setting beats the default. Those are now cleared on install.
|
|
119
|
+
|
|
120
|
+
- **A malformed tool response when a reply contained a backslash, tab or quote.** The
|
|
121
|
+
`mark_task_complete` response was assembled by a hand-written JSON builder that escaped only the
|
|
122
|
+
double quote. That was survivable while every value was a fixed keyword, but any free text
|
|
123
|
+
containing a Windows path or a tab produced JSON the client could not parse, losing the whole
|
|
124
|
+
response. All control characters are now escaped.
|
|
125
|
+
|
|
23
126
|
- **The reminder to prefer niro's tools no longer goes quiet mid-session.** Claude Code's
|
|
24
127
|
`Grep|Glob|Read` hook deduplicated to once per session, per repo, per branch — so it fired early
|
|
25
128
|
in a session and then said nothing, which is precisely when an agent drifts back to `grep`. It now
|
package/README.md
CHANGED
|
@@ -98,7 +98,9 @@ During `init` you'll be asked, **per environment variable**, which value to use
|
|
|
98
98
|
**Ask the agent**
|
|
99
99
|
> "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.
|
|
100
100
|
|
|
101
|
-
**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
|
|
101
|
+
**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 your repo `.gitignore`s (like a real `.env`) is **never** uploaded. One caveat: that filtering needs the folder to be a git repository. If you onboard a plain folder that is not a git repository, there is no `.gitignore` to honour and only the filename rules apply, so keep secrets out of it.
|
|
102
|
+
|
|
103
|
+
**What can be onboarded:** the folder has to look like a project — a build file (`package.json`, `pom.xml`, `go.mod`, `*.csproj`, …) at the folder itself or just below it. A monorepo root whose build files sit one or two levels down is fine. A folder with no build file is refused; pass `--force` if you really mean it. Your home folder, the filesystem root and system folders are refused outright, and `--force` does not override that — onboarding one of those would upload source from every project on the machine.
|
|
102
104
|
|
|
103
105
|
---
|
|
104
106
|
|
package/package.json
CHANGED
package/src/commands/add-repo.js
CHANGED
|
@@ -25,30 +25,57 @@ const { startWatch } = require("./watch");
|
|
|
25
25
|
const color = require("../lib/color");
|
|
26
26
|
const watchDaemon = require("../lib/watchDaemon");
|
|
27
27
|
const { login } = require("./login");
|
|
28
|
+
const onboardTarget = require("../lib/onboardTarget");
|
|
28
29
|
|
|
29
30
|
async function addRepo(pathArg, opts = {}) {
|
|
31
|
+
const targetRaw = pathArg || ".";
|
|
32
|
+
const isRemote = targetRaw.startsWith("http://") || targetRaw.startsWith("https://") || targetRaw.startsWith("git@");
|
|
33
|
+
|
|
34
|
+
// Backend URL as it looks BEFORE the login. Used for ONE thing only: the gate's
|
|
35
|
+
// already-registered lookup, which has to run before the login. It is NOT reused
|
|
36
|
+
// afterwards — a login can change which backend this session talks to, and tagging a
|
|
37
|
+
// watchState entry with a stale value stamps it with a backend that did not issue its
|
|
38
|
+
// gitId, which silently stops the daemon syncing that folder. See the same note in
|
|
39
|
+
// init.js. The authoritative value is re-read after ensureLoggedIn below.
|
|
40
|
+
const apiUrlBeforeLogin = api.currentBaseUrl();
|
|
41
|
+
|
|
42
|
+
// LOCAL FOLDER: validate and gate BEFORE the login, for two reasons. Refusing a folder
|
|
43
|
+
// should not first cost the user a browser device-flow. And an abandoned login used to
|
|
44
|
+
// swallow the refusal entirely: ensureLoggedIn returned null, the bare `return` printed
|
|
45
|
+
// nothing and left process.exitCode undefined, so `niro add repo ~ && echo ok` printed
|
|
46
|
+
// ok. A remote URL has no folder to gate, so it stays behind the login.
|
|
47
|
+
let targetDir = null;
|
|
48
|
+
if (!isRemote) {
|
|
49
|
+
targetDir = path.isAbsolute(targetRaw) ? path.resolve(targetRaw) : path.resolve(process.cwd(), targetRaw);
|
|
50
|
+
if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
|
|
51
|
+
console.error(color.red(`Not a directory: ${targetDir}`));
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Same onboarding gate as `niro init` — this command uploads a folder too, so it must
|
|
56
|
+
// refuse $HOME / the filesystem root on the same terms. A folder this backend already
|
|
57
|
+
// has a repo for is exempt from the BUILD-DESCRIPTOR half: that half gates new
|
|
58
|
+
// onboarding, and must not break the re-sync branch below for a descriptor-less folder
|
|
59
|
+
// onboarded by an earlier CLI. See src/lib/onboardTarget.js.
|
|
60
|
+
const known = watchState.get(targetDir, apiUrlBeforeLogin);
|
|
61
|
+
const alreadyRegistered = !!(known && known.gitId);
|
|
62
|
+
if (!onboardTarget.ensureOnboardable(targetDir, { ...opts, alreadyRegistered })) {
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
30
68
|
const creds = await ensureLoggedIn(opts);
|
|
31
69
|
if (!creds) return;
|
|
32
70
|
|
|
33
|
-
//
|
|
34
|
-
// another stack is never reused here.
|
|
71
|
+
// Authoritative backend, re-read AFTER the login (see the note above).
|
|
35
72
|
const currentApiUrl = api.currentBaseUrl();
|
|
36
73
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Remote URL?
|
|
40
|
-
if (targetRaw.startsWith("http://") || targetRaw.startsWith("https://") || targetRaw.startsWith("git@")) {
|
|
74
|
+
if (isRemote) {
|
|
41
75
|
await addRemoteRepo(targetRaw, opts);
|
|
42
76
|
return;
|
|
43
77
|
}
|
|
44
78
|
|
|
45
|
-
const targetDir = path.isAbsolute(targetRaw) ? path.resolve(targetRaw) : path.resolve(process.cwd(), targetRaw);
|
|
46
|
-
if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
|
|
47
|
-
console.error(color.red(`Not a directory: ${targetDir}`));
|
|
48
|
-
process.exitCode = 1;
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
79
|
// --sync-mode local|remote skips the interactive [1]/[2] sync-type choice.
|
|
53
80
|
// --no-confirm skips any confirmation prompt.
|
|
54
81
|
const syncMode = parseSyncMode(opts.syncMode);
|
package/src/commands/info.js
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
* NEVER prints secrets: it only emits project ids, aliases and status fields —
|
|
14
14
|
* no env-var values, tokens or credentials are touched here.
|
|
15
15
|
*/
|
|
16
|
+
const path = require("path");
|
|
16
17
|
const api = require("../lib/api");
|
|
18
|
+
const watchState = require("../lib/watchState");
|
|
19
|
+
const tempProject = require("../lib/tempProject");
|
|
17
20
|
const credentials = require("../lib/credentials");
|
|
18
21
|
const color = require("../lib/color");
|
|
19
22
|
const { resolveContext } = require("../lib/resolveContext");
|
|
@@ -91,8 +94,58 @@ async function info(pathArg, opts = {}) {
|
|
|
91
94
|
// A status fetch failure for one project must not abort the whole command.
|
|
92
95
|
status = null;
|
|
93
96
|
}
|
|
97
|
+
// Which local folders sync to THIS project, and on what branch. Purely local: ~/.niro/projects.json
|
|
98
|
+
// already records branch and projectId per folder.
|
|
99
|
+
//
|
|
100
|
+
// A developer gets ONE temporary project per project, and creating another RELABELS it onto the new
|
|
101
|
+
// branch. So with several worktrees open, exactly one of them is reflected by Niro and the others
|
|
102
|
+
// are not, with nothing on screen to say which. This is that missing line: it lets someone see that
|
|
103
|
+
// the temporary project is bound to worktree A on feature/x while they are standing in worktree B.
|
|
104
|
+
//
|
|
105
|
+
// Matching on projectId ALONE could never show the one folder this line exists for. Taking over a
|
|
106
|
+
// folder rewrites its entry's projectId to the TEMPORARY project's id, and /api/projects/resolve
|
|
107
|
+
// deliberately never returns temporary projects (they share the source project's git URL, so
|
|
108
|
+
// including them would make every teammate's resolution ambiguous). So for a taken-over worktree
|
|
109
|
+
// the two ids can never be equal, and the folder Niro actually reflects was the only one missing
|
|
110
|
+
// from the list, while the folder it does NOT reflect was the only one shown.
|
|
111
|
+
//
|
|
112
|
+
// takeover.sourceProjectId is the link back to the source project. It is written when the temporary
|
|
113
|
+
// project is created and cleared on discard, so it is present exactly while there is something to
|
|
114
|
+
// report. `niro release` already pairs the two this way.
|
|
115
|
+
//
|
|
116
|
+
// apiUrl comes from api.currentBaseUrl(), not opts: `--api-url` is refused outside `niro login`, so
|
|
117
|
+
// opts.apiUrl is always undefined here, and an undefined backend makes allLocal match entries
|
|
118
|
+
// belonging to ANY backend.
|
|
119
|
+
const localEntries = watchState.allLocal(api.currentBaseUrl())
|
|
120
|
+
.filter(e => e.projectId === p.project_id
|
|
121
|
+
|| (e.takeover && e.takeover.sourceProjectId === p.project_id));
|
|
122
|
+
const folders = localEntries.map(e => ({
|
|
123
|
+
path: e.folderPath,
|
|
124
|
+
branch: e.branch || null,
|
|
125
|
+
// The temporary project's own id when this folder is bound to one, else null.
|
|
126
|
+
temp_project_id: e.takeover && e.takeover.sourceProjectId === p.project_id ? e.projectId : null,
|
|
127
|
+
// Whether the temporary project is currently reflecting THIS folder. null when it has none, or
|
|
128
|
+
// when the server could not be asked.
|
|
129
|
+
reflected: null,
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// A temporary project can only be bound to one branch at a time, so listing several folders as
|
|
133
|
+
// "via temporary project X" implies they are all covered when at most one is. That is not
|
|
134
|
+
// hypothetical: two worktrees of one project end up sharing a repo id, and I have watched this
|
|
135
|
+
// command mark "you are here" against a folder the server had stopped reflecting. The branch the
|
|
136
|
+
// copy is on is the answer, and only the server knows it, so ask once per temporary project.
|
|
137
|
+
const tempIds = [...new Set(folders.map(f => f.temp_project_id).filter(Boolean))];
|
|
138
|
+
for (const tempId of tempIds) {
|
|
139
|
+
const owner = localEntries.find(e => e.projectId === tempId);
|
|
140
|
+
const heldBranch = await tempProject.currentBranch(tempId, owner && owner.gitId);
|
|
141
|
+
if (!heldBranch) continue; // could not ask — say nothing rather than guess
|
|
142
|
+
for (const f of folders) {
|
|
143
|
+
if (f.temp_project_id === tempId) f.reflected = f.branch === heldBranch;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
94
146
|
projects.push({
|
|
95
147
|
project_id: p.project_id,
|
|
148
|
+
folders,
|
|
96
149
|
alias: p.alias || null,
|
|
97
150
|
// Matched repo's registration kind (REMOTE_GIT / LOCAL_PATH / AGENT_UPLOAD),
|
|
98
151
|
// or null. Lets agents warn on remote-git projects (auto-build only on commits).
|
|
@@ -166,6 +219,23 @@ function printHuman(p) {
|
|
|
166
219
|
if (proj.doc_gen_status) bits.push(`docs: ${proj.doc_gen_status}`);
|
|
167
220
|
if (proj.updated_at) bits.push(`updated: ${proj.updated_at}`);
|
|
168
221
|
console.log(` - ${name}${bits.length ? " — " + color.dim(bits.join(", ")) : ""}`);
|
|
222
|
+
for (const f of proj.folders || []) {
|
|
223
|
+
// Marked so the folder you are STANDING IN is obvious at a glance; the whole point is telling
|
|
224
|
+
// "this worktree is the one Niro reflects" from "this one is not".
|
|
225
|
+
const here = path.resolve(f.path) === path.resolve(p.git_root) ? color.green(" <- you are here") : "";
|
|
226
|
+
// Say plainly which folder holds the temporary project. Without this the line reads as though
|
|
227
|
+
// Niro were answering from the source project for that folder, which is the opposite of the truth.
|
|
228
|
+
let temp = "";
|
|
229
|
+
if (f.temp_project_id && f.reflected === false) {
|
|
230
|
+
// The line that used to mislead. Say plainly that this folder is not the one being reflected.
|
|
231
|
+
temp = color.yellow(` — NOT reflected: temporary project ${f.temp_project_id} is on another branch`);
|
|
232
|
+
} else if (f.temp_project_id) {
|
|
233
|
+
temp = color.dim(` via temporary project ${f.temp_project_id}`)
|
|
234
|
+
+ (f.reflected === null ? color.dim(" (could not confirm)") : "");
|
|
235
|
+
}
|
|
236
|
+
console.log(color.dim(` syncing from ${f.path}`) +
|
|
237
|
+
(f.branch ? color.dim(` on branch ${f.branch}`) : "") + temp + here);
|
|
238
|
+
}
|
|
169
239
|
}
|
|
170
240
|
}
|
|
171
241
|
|
package/src/commands/init.js
CHANGED
|
@@ -36,6 +36,7 @@ const credentials = require("../lib/credentials");
|
|
|
36
36
|
const prompt = require("../lib/prompt");
|
|
37
37
|
const git = require("../lib/git");
|
|
38
38
|
const niroProjectFile = require("../lib/niroProjectFile");
|
|
39
|
+
const onboardTarget = require("../lib/onboardTarget");
|
|
39
40
|
const color = require("../lib/color");
|
|
40
41
|
const watchDaemon = require("../lib/watchDaemon");
|
|
41
42
|
const { login } = require("./login");
|
|
@@ -62,6 +63,36 @@ async function init(pathArg, opts = {}) {
|
|
|
62
63
|
process.exitCode = 1;
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
66
|
+
// Backend URL as it looks BEFORE the login. Used for ONE thing only: the gate's
|
|
67
|
+
// already-registered lookup, which has to happen before the login prompt.
|
|
68
|
+
//
|
|
69
|
+
// It is deliberately NOT reused after the login. currentBaseUrl() reads the backend out
|
|
70
|
+
// of the stored credentials, so a login can CHANGE it: `niro init --api-url <local>` on a
|
|
71
|
+
// machine with no session resolves the production URL here and the local one afterwards.
|
|
72
|
+
// Tagging a watchState entry with this stale value would stamp an entry with a backend
|
|
73
|
+
// that did not issue its gitId, after which watchState.get(dir, realBackend) returns null,
|
|
74
|
+
// the daemon never syncs the folder, and the next run re-registers a duplicate repo and
|
|
75
|
+
// re-uploads everything. The authoritative value is re-read below, after the login.
|
|
76
|
+
//
|
|
77
|
+
// Being wrong here only affects the alreadyRegistered waiver, which can at worst refuse a
|
|
78
|
+
// folder that --force reopens. It can never waive the protected-root refusal.
|
|
79
|
+
const apiUrlBeforeLogin = api.currentBaseUrl();
|
|
80
|
+
|
|
81
|
+
// Onboarding gate — runs BEFORE the login prompt and before any server call, so a
|
|
82
|
+
// refused folder leaves nothing behind: no registered repo, no orphan project, no
|
|
83
|
+
// .niro-project pin, no watcher. Refuses $HOME / the filesystem root outright, and
|
|
84
|
+
// refuses a folder with no build descriptor at it or just below it. --force covers only
|
|
85
|
+
// that second rule, and so does alreadyRegistered: a folder this backend already has a
|
|
86
|
+
// repo for must not start failing its re-run just because it has no build file.
|
|
87
|
+
// See src/lib/onboardTarget.js.
|
|
88
|
+
const knownEntry = watchState.get(targetDir, apiUrlBeforeLogin);
|
|
89
|
+
if (!onboardTarget.ensureOnboardable(targetDir, {
|
|
90
|
+
...opts,
|
|
91
|
+
alreadyRegistered: !!(knownEntry && knownEntry.gitId),
|
|
92
|
+
})) {
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
65
96
|
|
|
66
97
|
let creds = credentials.load();
|
|
67
98
|
if (!creds || !creds.token) {
|
|
@@ -71,8 +102,9 @@ async function init(pathArg, opts = {}) {
|
|
|
71
102
|
if (!creds || !creds.token) return;
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
105
|
+
// Authoritative backend, re-read AFTER the login: the login is what establishes which
|
|
106
|
+
// stack this session talks to. Every watchState read and write below uses THIS value, so
|
|
107
|
+
// an entry is always tagged with the backend that actually issued its gitId.
|
|
76
108
|
const currentApiUrl = api.currentBaseUrl();
|
|
77
109
|
|
|
78
110
|
const gitInfo = git.inspect(targetDir);
|
|
@@ -640,7 +672,13 @@ const CHILD_SCAN_SKIP = new Set([
|
|
|
640
672
|
"node_modules", "target", "dist", "build", "out", ".next", "vendor", "__pycache__",
|
|
641
673
|
]);
|
|
642
674
|
const CHILD_SCAN_MAX = 60; // bound the number of nested repos we resolve
|
|
643
|
-
|
|
675
|
+
// Immediate children plus three levels of grouping dirs. Real checkout layouts nest
|
|
676
|
+
// deeper than one grouping level (~/projects/github/<org>/<repo> is depth 3), and at
|
|
677
|
+
// depth 2 this scan silently found NOTHING there — so the "parent of indexed repos"
|
|
678
|
+
// refusal never fired for the most common layout. The walk never descends INTO a git
|
|
679
|
+
// repo, so the extra depth only costs readdir on grouping dirs: measured 9 dirs for a
|
|
680
|
+
// single repo and 50 for this monorepo, versus 5 and 13 at depth 2.
|
|
681
|
+
const CHILD_SCAN_MAX_DEPTH = 4;
|
|
644
682
|
|
|
645
683
|
/**
|
|
646
684
|
* Collect directories under `root` that are their OWN git repo (have a `.git` entry),
|
package/src/commands/mcp.js
CHANGED
|
@@ -37,4 +37,32 @@ function serve() {
|
|
|
37
37
|
require("../mcp/server");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `niro mcp receipt` — the per-developer off switch for the end-of-task line.
|
|
42
|
+
*
|
|
43
|
+
* The line itself is printed by the model from the tool result; nothing here displays it. This
|
|
44
|
+
* command only records the preference, which the bridge reads to decide whether to strip the
|
|
45
|
+
* receipt before the model ever sees it.
|
|
46
|
+
*
|
|
47
|
+
* `--pop` and `--hook` are gone. They served the Claude Code Stop hook, which was retired because
|
|
48
|
+
* Claude Code labels hook output "Stop says: …" — a system-warning framing for what is meant to be
|
|
49
|
+
* a quiet note from Niro.
|
|
50
|
+
*/
|
|
51
|
+
function receipt(opts = {}) {
|
|
52
|
+
const store = require("../lib/receiptStore");
|
|
53
|
+
|
|
54
|
+
if (opts.off || opts.on) {
|
|
55
|
+
const changed = store.setDisabled(!!opts.off);
|
|
56
|
+
const state = opts.off ? "off" : "on";
|
|
57
|
+
console.log(changed
|
|
58
|
+
? ` End-of-task Niro line is now ${state}.`
|
|
59
|
+
: ` End-of-task Niro line was already ${state}.`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(store.isDisabled()
|
|
64
|
+
? " End-of-task Niro line: off (turn it on with `niro mcp receipt --on`)"
|
|
65
|
+
: " End-of-task Niro line: on (turn it off with `niro mcp receipt --off`)");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { install, uninstall, serve, receipt };
|
package/src/commands/takeover.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Undo with `niro discard-temp-project` (deletes P'; routing falls back to P automatically).
|
|
15
15
|
*/
|
|
16
|
+
const path = require("path");
|
|
16
17
|
const api = require("../lib/api");
|
|
17
18
|
const color = require("../lib/color");
|
|
18
19
|
const folderSync = require("../lib/folderSync");
|
|
@@ -27,12 +28,14 @@ const warn = (s) => color.yellow(s);
|
|
|
27
28
|
const step = (s) => color.cyan(s);
|
|
28
29
|
|
|
29
30
|
async function takeover(pathArg, opts = {}) {
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// Second layer. src/index.js refuses `--api-url` for every command except `niro login`
|
|
32
|
+
// before the action ever runs, so a CLI user never reaches this. It stays for a
|
|
33
|
+
// PROGRAMMATIC caller (a test, a skill, a future internal call) that constructs opts
|
|
34
|
+
// directly and bypasses commander entirely: a per-command backend override mixes two
|
|
35
|
+
// stacks in one flow, and this is the one command that clones, uploads and tags watchState
|
|
36
|
+
// in a single pass.
|
|
34
37
|
if (opts.apiUrl) {
|
|
35
|
-
throw new Error("`--api-url` is not supported for `niro new-temp-project`
|
|
38
|
+
throw new Error("`--api-url` is not supported for `niro new-temp-project` (it would mix backends). "
|
|
36
39
|
+ "Select the backend with `niro login` / your config first, then run this without --api-url.");
|
|
37
40
|
}
|
|
38
41
|
const targetDir = require("path").resolve(pathArg || process.cwd());
|
|
@@ -71,6 +74,40 @@ async function takeover(pathArg, opts = {}) {
|
|
|
71
74
|
throw new Error("Server did not report which repo of the project matches this folder (git_id missing).");
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
// ONE temporary project per project. If a DIFFERENT folder already holds it, moving it here stops
|
|
78
|
+
// Niro reflecting that folder — and until now that happened silently: the other folder kept uploading
|
|
79
|
+
// its own branch into the copy, so two branches blended into one and every answer drawn from it was a
|
|
80
|
+
// mixture. Verified live with two worktrees.
|
|
81
|
+
//
|
|
82
|
+
// The SAME folder is deliberately not asked. Switching branch in place loses nothing: the copy was
|
|
83
|
+
// already reflecting this folder, and the branch it names has moved on. The server relabels it, which
|
|
84
|
+
// is right, and a prompt there would be pure friction.
|
|
85
|
+
const here = path.resolve(repoRoot);
|
|
86
|
+
const holder = watchState.allLocal(api.currentBaseUrl()).find((e) =>
|
|
87
|
+
e.takeover && e.takeover.sourceProjectId === source.project_id
|
|
88
|
+
&& path.resolve(e.folderPath) !== here);
|
|
89
|
+
if (holder) {
|
|
90
|
+
const onBranch = holder.branch ? ` on branch \`${holder.branch}\`` : "";
|
|
91
|
+
console.log(warn("\n Niro keeps ONE temporary project per project, and this project's is already"));
|
|
92
|
+
console.log(warn(" held by another folder:"));
|
|
93
|
+
console.log(` ${holder.folderPath}${onBranch}`);
|
|
94
|
+
console.log(color.dim(" Moving it here stops Niro reflecting that folder: it falls back to the original"));
|
|
95
|
+
console.log(color.dim(" project until `niro new-temp-project` is run there again."));
|
|
96
|
+
if (prompt.isNonInteractive()) {
|
|
97
|
+
// Refuse rather than hang, and rather than proceed. Proceeding is what caused the problem; hanging
|
|
98
|
+
// would break any agent or script that runs this unattended. The way forward is two explicit
|
|
99
|
+
// commands, which is clearer than a flag that quietly takes the slot from another folder.
|
|
100
|
+
throw new Error(
|
|
101
|
+
"Refusing to move the temporary project without confirmation (nothing to prompt on). "
|
|
102
|
+
+ "Niro keeps ONE temporary project per project, and this one is held by "
|
|
103
|
+
+ holder.folderPath + (holder.branch ? ` on branch ${holder.branch}` : "")
|
|
104
|
+
+ ". Run `niro discard-temp-project` in that folder first, then re-run this here.");
|
|
105
|
+
}
|
|
106
|
+
if (!(await prompt.confirm(" Move the temporary project to this folder?", false))) {
|
|
107
|
+
throw new Error("Cancelled — the temporary project stays where it is.");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
console.log(`\n Creating a temporary private project of ${color.bold(source.alias || source.project_id)}...`);
|
|
75
112
|
console.log(color.dim(" Your original project is not changed."));
|
|
76
113
|
|