@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 CHANGED
@@ -2,7 +2,66 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
- ## Unreleased
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 in your `.gitignore` (like a real `.env`) is **never** uploaded.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
@@ -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
- // Backend the CLI is pointed at — scopes watchState so a cached gitId from
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
- const targetRaw = pathArg || ".";
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);
@@ -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
 
@@ -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
- // The backend the CLI is pointed at. watchState entries (cached gitIds) are
75
- // scoped to this so a backend switch never reuses a gitId from another stack.
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
- const CHILD_SCAN_MAX_DEPTH = 2; // immediate children + one level of grouping dirs (services/, packages/, apps/)
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),
@@ -37,4 +37,32 @@ function serve() {
37
37
  require("../mcp/server");
38
38
  }
39
39
 
40
- module.exports = { install, uninstall, serve };
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 };
@@ -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
- // --api-url is threaded only into project resolution, not the clone/build POSTs or the agentApi upload
31
- // chain — so a global `--api-url X` would resolve against X but clone/upload/watch-tag against the DEFAULT
32
- // backend, mixing two stacks in one flow. Fail fast BEFORE any server mutation rather than corrupt state
33
- // across backends. (Full per-backend threading is future work; set the default backend via login/config.)
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` yet (it would mix backends). "
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