@niroai/niro 0.8.0 → 0.10.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,6 +2,116 @@
2
2
 
3
3
  Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
+ ## 0.10.0 — 2026-08-28
6
+
7
+ One command to get started, and a way to check what Niro is worth.
8
+
9
+ ### Upgrading
10
+
11
+ ```bash
12
+ npm install -g @niroai/niro@latest
13
+ ```
14
+
15
+ Nothing extra is needed from 0.9.0. Every existing command works exactly as before; the new
16
+ ones sit alongside them.
17
+
18
+ **`niro start` replaces four steps with one.** Getting started used to mean learning a sequence
19
+ from a README: install, `niro login`, change into the right folder, `niro init`, then
20
+ `niro mcp install`. Each step was fine on its own; the sequence was the problem, because the
21
+ entry point was documentation and documentation is optional. One user ran `niro init` in their
22
+ home folder. Typing `niro` on its own printed an eighteen-command help listing, which is the
23
+ worst possible first screen for someone who knows none of them.
24
+
25
+ ```bash
26
+ npx @niroai/niro start
27
+ ```
28
+
29
+ That signs you in, installs itself properly, shows you what it is about to index and asks
30
+ before uploading anything, builds the graph, connects your assistant, and offers to measure
31
+ the difference. Run it again in any other repo and it skips whatever is already done. It opens
32
+ by saying what Niro does and roughly how long this takes, and it asks whether you have an
33
+ access code before it installs anything, so nobody without one fills in a form that rejects
34
+ them at the end.
35
+
36
+ Two screens are worth calling out. Before uploading, it shows you the folder, the build files
37
+ it recognised, the module count and the git branch, and waits: the one screen worth reading
38
+ rather than skipping. And a folder it refuses, such as your home folder, now asks for a
39
+ different one instead of exiting, which is what made that mistake so easy to make.
40
+
41
+ `niro init`, `niro login` and `niro mcp install` are untouched. They are the scriptable path,
42
+ they take flags, and `niro start` simply calls them in order.
43
+
44
+ **`niro ab` measures what Niro changes.** Rather than take our word for it, Niro can run
45
+ *itself off* for half your sessions and show you the two halves side by side. While a window is
46
+ open each session lands in an "on" or "off" bucket by a deterministic split; the off half works
47
+ without Niro.
48
+
49
+ ```bash
50
+ niro ab enable # start a 14-day window (it states the scope and asks first)
51
+ niro ab report # the with-Niro / without-Niro comparison
52
+ niro ab disable # stop early
53
+ ```
54
+
55
+ It applies to your whole account, not just the repo you are standing in, and it says so before
56
+ starting. `niro start` offers it once, at the end of a first setup, and defaults to no.
57
+
58
+ **Setup prints about half as much.** A real run printed roughly 230 lines and only half were
59
+ ours. PM2's process table and generated systemd unit file, about 55 lines, are captured now and
60
+ shown only if something fails. The assistant list shows five clients and a count rather than all
61
+ fourteen with their config paths (`niro mcp install --list` for the rest). `niro init` stopped
62
+ telling you to run `niro mcp install` two seconds before the wizard did it for you.
63
+
64
+ **Output is indented consistently.** Printed lines used eight different indents across the CLI,
65
+ so nothing said what belonged to what. Everything now sits on one ladder: headings and step
66
+ markers, their messages one level in, detail one level further. Output from `niro init`, and
67
+ from the editor CLIs it shells out to, is folded into that ladder instead of printing flush
68
+ left in the middle of a step.
69
+
70
+ **Fixes.** The spinner and the upload progress bar wrote straight to the terminal and so
71
+ ignored indentation entirely. `niro mcp install --list` was added. Two em-dashes and one
72
+ invented URL were removed from text you read on screen.
73
+
74
+ ## 0.9.0 — 2026-08-26
75
+
76
+ One change to how the client waits, and two fixes for cases where Niro and your machine
77
+ disagreed about what still exists.
78
+
79
+ ### Upgrading
80
+
81
+ ```bash
82
+ npm install -g @niroai/niro@latest
83
+ ```
84
+
85
+ Nothing extra is needed from 0.8.0. Against an older server the waiting change simply does not
86
+ engage, because the client decides from the server's answer rather than from a version.
87
+
88
+ **Creating a working copy no longer times out.** It takes 44 to 60 seconds, and it used to run
89
+ inside a single request, against a gateway that gives up at 60. Three at once on 26 August all
90
+ came back as gateway error pages, and because such an error says nothing about whether the work
91
+ finished, the real cause underneath went unread for an afternoon. The client now asks the server
92
+ to do it in the background and waits for it to finish, showing progress. A dropped connection
93
+ while waiting is not treated as a failure, because the copy is still being made on the server; an
94
+ expired login is, because waiting cannot fix it.
95
+
96
+ **Discarding a working copy the server had already deleted.** A working copy can go away
97
+ without your machine being told: someone deletes it in the web UI, or the server reaps it
98
+ after it has sat idle for two weeks. The folder on your machine kept a registration pointing
99
+ at it, and `niro discard-temp-project`, which is what clears that registration, failed before
100
+ it got that far. The registration is also what makes `niro new-temp-project` in a second
101
+ worktree refuse, advising you to run discard in the other folder, so the advice named the one
102
+ command that could not work. Discard now treats a project the server says is missing as
103
+ nothing left to delete: it skips the server call and does the local half, pointing the folder
104
+ back at the original project. A server it could not reach, or one that refuses because the
105
+ copy belongs to another developer, still fails loudly and changes nothing, which matters
106
+ because the server answers both of those with the same status code as a deleted project.
107
+
108
+ **A detached checkout stopped Niro answering.** During `git bisect`, or after checking out a
109
+ tag, your repo is on no branch. `git rev-parse --abbrev-ref HEAD` does not fail there, it
110
+ prints the word `HEAD`, and Niro passed that on as though it were a branch name. Nothing
111
+ indexes a branch called `HEAD`, so the server treated every such question as coming from a
112
+ diverged branch and declined to answer it, for the whole bisect. Niro now reports no branch,
113
+ which is what the server already had a rule for, and answers from the indexed project.
114
+
5
115
  ## 0.8.0 — 2026-08-26
6
116
 
7
117
  Needed before a server has the layered store turned on. On an older client the new
package/README.md CHANGED
@@ -53,6 +53,23 @@ By default the CLI talks to Niro's cloud (`https://aiorchestrator.niroai.dev`) a
53
53
 
54
54
  ## 60-second setup
55
55
 
56
+ ```bash
57
+ cd ~/code/my-service
58
+ npx @niroai/niro start
59
+ ```
60
+
61
+ That is the whole thing. `niro start` signs you in (browser), installs itself properly,
62
+ shows you what it is about to index and asks before uploading anything, builds the graph,
63
+ connects your AI assistant, and finally offers to measure what difference Niro makes
64
+ (Scenario 12; optional, defaults to no). Run it again in any other repo; it skips whatever
65
+ is already done.
66
+
67
+ Niro is in private beta, so `start` asks for an **access code** first (the browser signup form
68
+ asks for it under that name). No code yet? Go to [www.niro.ai](https://www.niro.ai) and click
69
+ **Request Access**. Answering "no" stops the setup immediately, having installed nothing.
70
+
71
+ Prefer the individual commands (or scripting it)? They all still work, and take flags:
72
+
56
73
  ```bash
57
74
  niro login # opens your browser to approve this device
58
75
  cd ~/code/my-service
@@ -250,10 +267,46 @@ It does **not** sign you out — your `niro login` session in `~/.niro/credentia
250
267
 
251
268
  ---
252
269
 
270
+ ## Scenario 12 — Measure what Niro actually changes
271
+
272
+ Rather than take our word for it, you can have Niro run **itself off** for half your sessions
273
+ and compare the two halves. While a window is open, each MCP session is put in an "on" or
274
+ "off" bucket by a deterministic 50/50 split; the "off" half works without Niro. `niro ab
275
+ report` then shows the two side by side.
276
+
277
+ Two things to know before you start one:
278
+
279
+ - It applies to your **whole account**, not just the repo you are standing in.
280
+ - It runs for a fixed window, **14 days** by default, and you can stop it at any time.
281
+
282
+ **Terminal**
283
+ ```bash
284
+ niro ab # is a window open, and until when?
285
+ niro ab enable # start a 14-day window (states the scope, asks first)
286
+ niro ab enable --days 30 --name "Q3 trial"
287
+ niro ab enable --yes # skip the confirm (scripts / CI)
288
+ niro ab report # the with-Niro / without-Niro comparison
289
+ niro ab report --json # same, machine-readable
290
+ niro ab disable # stop early; every session uses Niro again
291
+ ```
292
+
293
+ The report compares session count, median session time, median tool calls per session, median
294
+ Niro tool calls, and median context size. A figure the server has not recorded reads
295
+ `not measured` rather than `0`, because `0` is a real result here: an off-bucket session
296
+ genuinely makes zero Niro tool calls.
297
+
298
+ `niro start` offers this once, at the end of your first setup, and defaults to no.
299
+
300
+ **Ask the agent**
301
+ > "Is Niro A/B measurement on?" / "Show me the A/B comparison." — the agent runs `niro ab`.
302
+
303
+ ---
304
+
253
305
  ## Command reference
254
306
 
255
307
  | Command | What it does |
256
308
  |---|---|
309
+ | `niro start [path]` | First-time setup end to end: sign in, onboard this folder, connect an assistant |
257
310
  | `niro login` / `logout` / `whoami` | Authenticate / sign out / show current account |
258
311
  | `niro init [path]` | Onboard a folder end-to-end (upload, project, env scan, build, watch) |
259
312
  | `niro info [path]` | What Niro knows about this folder (repo, branch, project(s), status) |
@@ -267,9 +320,10 @@ It does **not** sign you out — your `niro login` session in `~/.niro/credentia
267
320
  | `niro watch [path]` / `niro service …` | Foreground watch / manage the auto-sync daemon |
268
321
  | `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) |
269
322
  | `niro mcp uninstall` | Disconnect an AI assistant — reverses `mcp install` (`--client <id>` / `--all`, `-y` to skip confirm) |
323
+ | `niro ab status\|enable\|disable\|report` | Run Niro off for half your sessions for a fixed window, then compare (`--days`, `--name`, `--yes`, `--json`) |
270
324
  | `niro delete [project]` | Older whole-project delete (prefer `niro remove --project`) |
271
325
 
272
- 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.
326
+ Add `--json` to `whoami`, `info`, `status`, `projects`, `build`, `ab report`, and `edit --list-env` for machine-readable output. Add `--help` to any command for its full flags.
273
327
 
274
328
  ---
275
329
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -0,0 +1,198 @@
1
+ /**
2
+ * `niro ab` — turn the A/B measurement window on or off, and read the result.
3
+ *
4
+ * What it measures: while a window is open, ai-assistant puts each MCP session in an "on" or
5
+ * "off" bucket by a deterministic 50/50 split of the session id. In the OFF bucket your
6
+ * assistant works WITHOUT Niro. Comparing the two buckets is the only way to see what Niro
7
+ * changes for you rather than take our word for it.
8
+ *
9
+ * Two things about the scope, both said out loud before anything is switched on:
10
+ * - It is per ACCOUNT, not per repo or per machine, so it reaches sessions beyond the
11
+ * folder you are standing in.
12
+ * - It runs for a fixed window (14 days by default) and can be stopped at any time.
13
+ *
14
+ * These endpoints live on ai-assistant, NOT the orchestrator every other command talks to,
15
+ * so the base URL is resolved through the MCP installer's own resolver rather than a second
16
+ * copy of that precedence. Deliberately no --url flag here, for the reason index.js refuses
17
+ * --api-url outside login: a per-command backend override reads one server and writes
18
+ * another. A mismatched pair fails as a 401 from the assistant rather than doing something
19
+ * quietly wrong.
20
+ */
21
+ const api = require("../lib/api");
22
+ const credentials = require("../lib/credentials");
23
+ const color = require("../lib/color");
24
+ const prompt = require("../lib/prompt");
25
+ const { resolveMcpServerUrl } = require("../mcp/setup");
26
+
27
+ const ok = (s) => color.green(s);
28
+ const warn = (s) => color.yellow(s);
29
+ const fail = (s) => color.red(s);
30
+
31
+ const DEFAULT_WINDOW_DAYS = 14;
32
+
33
+ /** The ai-assistant base for this session, by the same precedence `niro mcp install` uses. */
34
+ function assistantUrl() {
35
+ return resolveMcpServerUrl(null).url;
36
+ }
37
+
38
+ function call(method, path, opts = {}) {
39
+ // Checked here rather than left to the server: without it an unauthenticated user gets a
40
+ // raw "GET /api/abtest/mode failed: Unauthorized (HTTP 401)", while every other command in
41
+ // the CLI says "Not logged in. Run `niro login` first." requireCreds throws the ENOAUTH
42
+ // that index.js already knows how to print.
43
+ credentials.requireCreds();
44
+ return api.request(method, path, { ...opts, apiUrl: assistantUrl() });
45
+ }
46
+
47
+ /** A run's window as a plain sentence, or null when there is no window. */
48
+ function describeWindow(run) {
49
+ if (!run || !run.window_start) return null;
50
+ const start = String(run.window_start).slice(0, 10);
51
+ const end = run.window_end ? String(run.window_end).slice(0, 10) : "no end date";
52
+ return `${start} to ${end}`;
53
+ }
54
+
55
+ function isActive(run) {
56
+ return !!(run && run.status === "active");
57
+ }
58
+
59
+ async function status() {
60
+ const run = await call("GET", "/api/abtest/mode");
61
+ if (!isActive(run)) {
62
+ console.log(`\n A/B measurement is ${color.bold("off")}.`);
63
+ console.log(` Start a ${DEFAULT_WINDOW_DAYS}-day window with ${color.cyan("niro ab enable")}.\n`);
64
+ return;
65
+ }
66
+ console.log(`\n A/B measurement is ${ok("on")}${run.name ? ` (${run.name})` : ""}.`);
67
+ const window = describeWindow(run);
68
+ if (window) console.log(` Window: ${window}`);
69
+ console.log(` Half your sessions are running without Niro so the two can be compared.`);
70
+ console.log(`\n ${color.cyan("niro ab report")} see the comparison`);
71
+ console.log(` ${color.cyan("niro ab disable")} stop early\n`);
72
+ }
73
+
74
+ /**
75
+ * Starting a window changes how HALF of every future session behaves, account-wide, so the
76
+ * consequence is stated and confirmed rather than assumed from the fact that the command was
77
+ * typed. --yes is the scripted escape.
78
+ */
79
+ async function enable(opts = {}) {
80
+ const days = parseDays(opts.days);
81
+ if (days === null) {
82
+ console.error(fail(` --days must be a whole number of days, got "${opts.days}".`));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+
87
+ const existing = await call("GET", "/api/abtest/mode").catch(() => null);
88
+ if (isActive(existing)) {
89
+ console.log(`\n ${warn("A/B measurement is already on.")}`);
90
+ const window = describeWindow(existing);
91
+ if (window) console.log(` Window: ${window}`);
92
+ console.log(` ${color.cyan("niro ab report")} for the comparison so far.\n`);
93
+ return;
94
+ }
95
+
96
+ if (!opts.yes) {
97
+ console.log(`\n This runs Niro ${color.bold("off")} for half your sessions for ${days} days,`);
98
+ console.log(" so the two halves can be compared. It applies to your whole account,");
99
+ console.log(" not just this repo, and you can stop it at any time.\n");
100
+ if (prompt.isNonInteractive()) {
101
+ console.error(fail(" No terminal to confirm on. Pass --yes if you mean it."));
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ if (!(await prompt.confirm(" Start the measurement window?", false))) {
106
+ console.log("\n Left off. Nothing changed.\n");
107
+ return;
108
+ }
109
+ }
110
+
111
+ const run = await call("POST", "/api/abtest/mode/enable", {
112
+ query: { windowDays: days, name: opts.name || undefined },
113
+ });
114
+ console.log(`\n ${ok("✓")} A/B measurement is on for ${days} days.`);
115
+ const window = describeWindow(run);
116
+ if (window) console.log(` Window: ${window}`);
117
+ console.log(` Come back with ${color.cyan("niro ab report")}, or stop early with ${color.cyan("niro ab disable")}.\n`);
118
+ }
119
+
120
+ async function disable() {
121
+ const existing = await call("GET", "/api/abtest/mode").catch(() => null);
122
+ if (!isActive(existing)) {
123
+ console.log(`\n A/B measurement was already off. Nothing changed.\n`);
124
+ return;
125
+ }
126
+ await call("POST", "/api/abtest/mode/disable");
127
+ console.log(`\n ${ok("✓")} A/B measurement stopped. Every session uses Niro again.`);
128
+ console.log(` The result so far is still readable with ${color.cyan("niro ab report")}.\n`);
129
+ }
130
+
131
+ /**
132
+ * The comparison, as two columns.
133
+ *
134
+ * Rows are only printed when the server actually returned a number for them: a median that
135
+ * never arrived must read as absent, not as zero, because zero is a real and very different
136
+ * measurement here (an off-bucket session genuinely makes 0 Niro tool calls).
137
+ */
138
+ async function report(opts = {}) {
139
+ const r = await call("GET", "/api/abtest/report", {
140
+ query: opts.runId ? { runId: opts.runId } : undefined,
141
+ });
142
+
143
+ if (opts.json) {
144
+ console.log(JSON.stringify(r, null, 2));
145
+ return;
146
+ }
147
+
148
+ const on = r.niro_on || {};
149
+ const off = r.niro_off || {};
150
+
151
+ console.log(`\n A/B comparison${r.run_name ? `: ${r.run_name}` : ""}`);
152
+ const window = describeWindow(r);
153
+ if (window) console.log(color.dim(` ${window}${r.active ? ", still running" : ", ended"}`));
154
+
155
+ if (!r.total_sessions) {
156
+ console.log(`\n No sessions recorded yet${r.message ? `: ${r.message}` : "."}`);
157
+ console.log(" Use your assistant for a while, then run this again.\n");
158
+ return;
159
+ }
160
+
161
+ console.log(`\n ${pad("", 34)}${pad("with Niro", 14)}without Niro`);
162
+ console.log(` ${color.dim("-".repeat(60))}`);
163
+ row("Sessions", on.session_count, off.session_count);
164
+ row("Median session time (ms)", on.median_session_ms, off.median_session_ms);
165
+ row("Median tool calls per session", on.median_tool_calls, off.median_tool_calls);
166
+ row("Median Niro tool calls", on.median_niro_tool_calls, off.median_niro_tool_calls);
167
+ row("Median context (bytes)", on.median_context_bytes, off.median_context_bytes);
168
+ console.log(`\n ${r.total_sessions} sessions in total.`);
169
+ if (!r.active) console.log(color.dim(" This window has ended."));
170
+ console.log("");
171
+ }
172
+
173
+ function row(label, a, b) {
174
+ console.log(` ${pad(label, 34)}${pad(fmt(a), 14)}${fmt(b)}`);
175
+ }
176
+
177
+ function fmt(v) {
178
+ // Spelled out rather than a dash: this cell has to be distinguishable from 0 at a
179
+ // glance, and 0 is a real measurement here (an off-bucket session makes 0 Niro calls).
180
+ if (v === null || v === undefined) return color.dim("not measured");
181
+ return typeof v === "number" ? v.toLocaleString("en-GB") : String(v);
182
+ }
183
+
184
+ /** Pads on the VISIBLE length, so a dimmed cell still lines up with a plain one. */
185
+ function pad(s, width) {
186
+ const visible = String(s).replace(/\x1b\[[0-9;]*m/g, "").length;
187
+ return String(s) + " ".repeat(Math.max(0, width - visible));
188
+ }
189
+
190
+ /** null for anything that is not a positive whole number of days. */
191
+ function parseDays(raw) {
192
+ if (raw === undefined || raw === null || raw === "") return DEFAULT_WINDOW_DAYS;
193
+ const n = Number(raw);
194
+ if (!Number.isInteger(n) || n <= 0) return null;
195
+ return n;
196
+ }
197
+
198
+ module.exports = { status, enable, disable, report, DEFAULT_WINDOW_DAYS, parseDays, pad, fmt };
@@ -47,7 +47,7 @@ async function addProject(opts = {}) {
47
47
  localByGitId[entry.gitId] = entry;
48
48
  }
49
49
 
50
- console.log(`\n ${color.bold("Available repos:")}`);
50
+ console.log(`\n ${color.bold("Available repos:")}`);
51
51
  allRepos.forEach((repo, i) => {
52
52
  const gitId = repo.id || repo.gitId;
53
53
  const url = repo.repository_url || repo.repositoryUrl || "";
@@ -79,12 +79,12 @@ async function addProject(opts = {}) {
79
79
  ? color.dim(" last synced " + timeSince(local.lastSyncedAt))
80
80
  : "";
81
81
 
82
- console.log(` [${i + 1}] ${color.bold(displayName)} ${label}${syncInfo}`);
82
+ console.log(` [${i + 1}] ${color.bold(displayName)} ${label}${syncInfo}`);
83
83
  });
84
- console.log(` [${allRepos.length + 1}] Register a new repo first (niro add repo)`);
84
+ console.log(` [${allRepos.length + 1}] Register a new repo first (niro add repo)`);
85
85
 
86
86
  const selectionRaw = await prompt.ask(
87
- `\n Select repos to include (e.g. 1,3)`,
87
+ `\n Select repos to include (e.g. 1,3)`,
88
88
  ""
89
89
  );
90
90
  if (!selectionRaw.trim()) {
@@ -114,10 +114,10 @@ async function addProject(opts = {}) {
114
114
  // on create below.
115
115
  let alias;
116
116
  while (true) {
117
- const candidate = await prompt.ask(" Project name", defaultName);
117
+ const candidate = await prompt.ask(" Project name", defaultName);
118
118
  if (!candidate) { console.error(color.red("Project name is required.")); return; }
119
119
  if (await aliasTaken(candidate, opts)) {
120
- console.log(color.yellow(` "${candidate}" already exists — pick another.`));
120
+ console.log(color.yellow(` "${candidate}" already exists — pick another.`));
121
121
  } else {
122
122
  alias = candidate;
123
123
  break;
@@ -130,7 +130,7 @@ async function addProject(opts = {}) {
130
130
  gitIdToMapping[repo.id || repo.gitId] = {};
131
131
  }
132
132
 
133
- console.log(`\n Creating project "${color.bold(alias)}"...`);
133
+ console.log(`\n Creating project "${color.bold(alias)}"...`);
134
134
  const project = await api.post("/api/projects", {
135
135
  alias,
136
136
  git_id_to_mapping: gitIdToMapping,
@@ -148,11 +148,11 @@ async function addProject(opts = {}) {
148
148
  }
149
149
  }
150
150
 
151
- console.log(` ${color.green("✓")} Project created: ${color.bold(alias)}`);
151
+ console.log(` ${color.green("✓")} Project created: ${color.bold(alias)}`);
152
152
 
153
- const shouldBuild = await prompt.confirm(" Trigger a full index build now?", true);
153
+ const shouldBuild = await prompt.confirm(" Trigger a full index build now?", true);
154
154
  if (shouldBuild) {
155
- console.log(` Triggering build...`);
155
+ console.log(` Triggering build...`);
156
156
  await api.post(
157
157
  `/api/graph/${encodeURIComponent(projectId)}/build`,
158
158
  {},
@@ -161,7 +161,7 @@ async function addProject(opts = {}) {
161
161
  if (opts.watch !== false) {
162
162
  await watchStatus(projectId);
163
163
  } else {
164
- console.log(` Build triggered. Run ${color.cyan("niro status")} to track progress.`);
164
+ console.log(` Build triggered. Run ${color.cyan("niro status")} to track progress.`);
165
165
  }
166
166
  }
167
167
 
@@ -87,7 +87,7 @@ async function addRepo(pathArg, opts = {}) {
87
87
  const reuse = noConfirm
88
88
  ? true
89
89
  : await prompt.confirm(
90
- ` This folder is already registered (gitId: ${color.dim(existing.gitId)}). Re-sync without re-registering?`,
90
+ ` This folder is already registered (gitId: ${color.dim(existing.gitId)}). Re-sync without re-registering?`,
91
91
  true
92
92
  );
93
93
  if (reuse) {
@@ -101,7 +101,7 @@ async function addRepo(pathArg, opts = {}) {
101
101
  let branch = opts.branch || null;
102
102
 
103
103
  if (gitInfo.repoRoot) {
104
- console.log(`\n Detected a git repository.`);
104
+ console.log(`\n Detected a git repository.`);
105
105
  let choice;
106
106
  if (syncMode) {
107
107
  choice = syncMode === "remote" ? "2" : "1";
@@ -109,14 +109,14 @@ async function addRepo(pathArg, opts = {}) {
109
109
  choice = "1"; // default to local sync without prompting
110
110
  } else {
111
111
  choice = await prompt.ask(
112
- ` Sync on: [1] local file changes [2] commits to remote`,
112
+ ` Sync on: [1] local file changes [2] commits to remote`,
113
113
  "1"
114
114
  );
115
115
  }
116
116
  if (choice.trim() === "2") {
117
117
  // Register as remote repo using the origin URL
118
118
  if (!gitInfo.originUrl) {
119
- console.error(color.red(" No remote origin found. Cannot register as remote repo."));
119
+ console.error(color.red(" No remote origin found. Cannot register as remote repo."));
120
120
  process.exitCode = 1;
121
121
  return;
122
122
  }
@@ -128,10 +128,10 @@ async function addRepo(pathArg, opts = {}) {
128
128
  const confirmed = (noConfirm || prompt.isNonInteractive())
129
129
  ? true
130
130
  : await prompt.confirm(
131
- ` Detected branch: ${color.cyan(gitInfo.branch)}. Use this?`,
131
+ ` Detected branch: ${color.cyan(gitInfo.branch)}. Use this?`,
132
132
  true
133
133
  );
134
- branch = confirmed ? gitInfo.branch : await prompt.ask(" Branch name", "main");
134
+ branch = confirmed ? gitInfo.branch : await prompt.ask(" Branch name", "main");
135
135
  }
136
136
  }
137
137
 
@@ -141,10 +141,10 @@ async function addRepo(pathArg, opts = {}) {
141
141
  const displayName = opts.name
142
142
  || ((noConfirm || prompt.isNonInteractive())
143
143
  ? folderName
144
- : await prompt.ask(" Repo display name", folderName));
144
+ : await prompt.ask(" Repo display name", folderName));
145
145
 
146
146
  // Build manifest
147
- process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
147
+ process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
148
148
  const manifest = folderSync.buildManifest(targetDir);
149
149
  process.stdout.write(` ${manifest.length} files found.\n`);
150
150
 
@@ -154,7 +154,7 @@ async function addRepo(pathArg, opts = {}) {
154
154
  let excludePatterns;
155
155
 
156
156
  // Register first (server allocates workspace dir) so we can call /filter
157
- console.log(` Registering repo with server...`);
157
+ console.log(` Registering repo with server...`);
158
158
  const cliModuleExcludes = Array.isArray(opts.moduleExclude) ? opts.moduleExclude : [];
159
159
  const cliFileExcludes = Array.isArray(opts.fileExclude) ? opts.fileExclude : [];
160
160
  gitId = await agentApi.registerRepo({
@@ -164,7 +164,7 @@ async function addRepo(pathArg, opts = {}) {
164
164
  moduleExcludePatterns: cliModuleExcludes,
165
165
  fileExcludePatterns: cliFileExcludes,
166
166
  });
167
- console.log(` gitId: ${color.dim(gitId)}`);
167
+ console.log(` gitId: ${color.dim(gitId)}`);
168
168
 
169
169
  // Offer backend-suggested module exclusions (skipped if --defaults or
170
170
  // --module-exclude already supplied or stdin is non-interactive).
@@ -172,31 +172,31 @@ async function addRepo(pathArg, opts = {}) {
172
172
  await offerModuleExcludeSuggestions(gitId, opts, cliModuleExcludes);
173
173
 
174
174
  // Get filter recommendation
175
- console.log(` Analyzing project structure...`);
175
+ console.log(` Analyzing project structure...`);
176
176
  const filterResult = await agentApi.filterManifest(gitId, manifest);
177
177
  folderSync.warnOversizeFiles(manifest, filterResult, (m) => console.log(color.yellow(m)));
178
178
  const includedCount = filterResult.included_count || filterResult.includedCount || 0;
179
179
  const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
180
180
 
181
- console.log(`\n ${color.green("Will sync:")} ${includedCount} files`);
182
- console.log(` ${color.yellow("Excluded:")} ${excludedCount} files`);
181
+ console.log(`\n ${color.green("Will sync:")} ${includedCount} files`);
182
+ console.log(` ${color.yellow("Excluded:")} ${excludedCount} files`);
183
183
 
184
184
  const suggestedIncludes = filterResult.include_patterns || filterResult.includePatterns || [];
185
185
  const suggestedExcludes = filterResult.exclude_patterns || filterResult.excludePatterns || [];
186
186
 
187
187
  if (suggestedIncludes.length > 0) {
188
- console.log(` Include patterns: ${color.dim(suggestedIncludes.join(", "))}`);
188
+ console.log(` Include patterns: ${color.dim(suggestedIncludes.join(", "))}`);
189
189
  }
190
190
  if (suggestedExcludes.length > 0) {
191
- console.log(` Exclude patterns: ${color.dim(suggestedExcludes.join(", "))}`);
191
+ console.log(` Exclude patterns: ${color.dim(suggestedExcludes.join(", "))}`);
192
192
  }
193
193
 
194
194
  const adjust = (noConfirm || prompt.isNonInteractive())
195
195
  ? false
196
- : await prompt.confirm(" Adjust patterns?", false);
196
+ : await prompt.confirm(" Adjust patterns?", false);
197
197
  if (adjust) {
198
- const addIncludes = await prompt.ask(" Add include patterns (comma-separated, e.g. src/**)", "");
199
- const addExcludes = await prompt.ask(" Add exclude patterns (comma-separated, e.g. docs/**)", "");
198
+ const addIncludes = await prompt.ask(" Add include patterns (comma-separated, e.g. src/**)", "");
199
+ const addExcludes = await prompt.ask(" Add exclude patterns (comma-separated, e.g. docs/**)", "");
200
200
  if (addIncludes) suggestedIncludes.push(...addIncludes.split(",").map(s => s.trim()).filter(Boolean));
201
201
  if (addExcludes) suggestedExcludes.push(...addExcludes.split(",").map(s => s.trim()).filter(Boolean));
202
202
  }
@@ -228,7 +228,7 @@ async function addRepo(pathArg, opts = {}) {
228
228
  excluded: excludePatterns,
229
229
  });
230
230
  } catch (err) {
231
- console.log(color.yellow(` ⚠ Could not save patterns to server: ${err.message}`));
231
+ console.log(color.yellow(` ⚠ Could not save patterns to server: ${err.message}`));
232
232
  }
233
233
  }
234
234
 
@@ -244,12 +244,12 @@ async function addRepo(pathArg, opts = {}) {
244
244
  async function addRemoteRepo(url, opts) {
245
245
  const noConfirm = opts.confirm === false;
246
246
  const branch = opts.branch
247
- || ((noConfirm || prompt.isNonInteractive()) ? "main" : await prompt.ask(" Branch", "main"));
247
+ || ((noConfirm || prompt.isNonInteractive()) ? "main" : await prompt.ask(" Branch", "main"));
248
248
  const tokenId = opts.tokenId || null;
249
249
  const cliModuleExcludes = Array.isArray(opts.moduleExclude) ? opts.moduleExclude : [];
250
250
  const cliFileExcludes = Array.isArray(opts.fileExclude) ? opts.fileExclude : [];
251
251
 
252
- console.log(` Registering remote repo: ${color.cyan(url)} (branch: ${branch})`);
252
+ console.log(` Registering remote repo: ${color.cyan(url)} (branch: ${branch})`);
253
253
  const reqBody = { repository_url: url, branch, token_id: tokenId };
254
254
  if (cliModuleExcludes.length > 0) reqBody.module_exclude_patterns = cliModuleExcludes;
255
255
  if (cliFileExcludes.length > 0) reqBody.file_exclude_patterns = cliFileExcludes;
@@ -258,12 +258,12 @@ async function addRemoteRepo(url, opts) {
258
258
  if (!rec || !rec.gitId) {
259
259
  throw new Error(`Failed to register remote repo: ${(rec && rec.status) || "unknown error"}`);
260
260
  }
261
- console.log(` ${color.green("✓")} Remote repo registered. gitId: ${color.dim(rec.gitId)}`);
261
+ console.log(` ${color.green("✓")} Remote repo registered. gitId: ${color.dim(rec.gitId)}`);
262
262
 
263
263
  const { offerModuleExcludeSuggestions } = require("../lib/moduleExcludeSuggestions");
264
264
  await offerModuleExcludeSuggestions(rec.gitId, opts, cliModuleExcludes);
265
265
 
266
- console.log(` Run ${color.cyan("niro add project")} to attach it to a project.`);
266
+ console.log(` Run ${color.cyan("niro add project")} to attach it to a project.`);
267
267
  }
268
268
 
269
269
  /** Parse --sync-mode local|remote. Returns the mode, or null if unset. Throws on garbage. */