@niroai/niro 0.9.0 → 0.10.1

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.
@@ -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. */
@@ -25,7 +25,7 @@ async function build(projectIdArg, opts = {}) {
25
25
  if (asJson) {
26
26
  emitJson({ event: "triggered", project_id: projectId, force_full_rebuild: force });
27
27
  } else {
28
- console.log(`${color.green("✓")} Build triggered for ${color.bold(projectId)}.`);
28
+ console.log(` ${color.green("✓")} Build triggered for ${color.bold(projectId)}.`);
29
29
  }
30
30
  if (opts.watch === false) return;
31
31
  await watchStatus(projectId, { json: asJson });
@@ -75,7 +75,7 @@ async function watchStatusJson(projectId) {
75
75
  }
76
76
 
77
77
  async function watchStatusHuman(projectId) {
78
- const sp = spinner.start(`Waiting for build to start... [${projectId}]`);
78
+ const sp = spinner.start(` Waiting for build to start... [${projectId}]`);
79
79
  let lastStatus = null;
80
80
  let lastLog = null;
81
81
  try {
@@ -84,7 +84,7 @@ async function watchStatusHuman(projectId) {
84
84
  try {
85
85
  data = await api.get(`/api/graph/${encodeURIComponent(projectId)}/status`);
86
86
  } catch (err) {
87
- sp.stop(`Failed to fetch status: ${err.message}`);
87
+ sp.stop(` Failed to fetch status: ${err.message}`);
88
88
  process.exitCode = 1;
89
89
  return;
90
90
  }
@@ -92,7 +92,7 @@ async function watchStatusHuman(projectId) {
92
92
  const logMessage = data.log_message || data.logMessage;
93
93
  if (st !== lastStatus || logMessage !== lastLog) {
94
94
  const coloredSt = colorizeStatus(st);
95
- const label = logMessage ? `${coloredSt} — ${logMessage}` : coloredSt;
95
+ const label = ` ${logMessage ? `${coloredSt}: ${logMessage}` : coloredSt}`;
96
96
  sp.update(label);
97
97
  lastStatus = st;
98
98
  lastLog = logMessage;
@@ -100,7 +100,7 @@ async function watchStatusHuman(projectId) {
100
100
  if (TERMINAL.has(st)) {
101
101
  const mark = st === "COMPLETED" ? color.green("✓") : color.red("✗");
102
102
  const tint = st === "COMPLETED" ? color.green : st === "FAILED" || st === "CANCELLED" ? color.red : (s) => s;
103
- sp.stop(`${mark} Build ${tint(st.toLowerCase())}.`);
103
+ sp.stop(` ${mark} Build ${tint(st.toLowerCase())}.`);
104
104
  if (st !== "COMPLETED") process.exitCode = 1;
105
105
  return;
106
106
  }
@@ -22,7 +22,7 @@ async function deleteProject(projectIdArg, opts = {}) {
22
22
 
23
23
  if (!opts.yes) {
24
24
  console.log(`${color.yellow("⚠")} About to delete project ${color.bold(projectId)}.`);
25
- console.log(color.dim(" This removes the project and its graph from the server. Registered repos stay."));
25
+ console.log(color.dim(" This removes the project and its graph from the server. Registered repos stay."));
26
26
  const typed = await prompt.ask(`Type the project name to confirm`, "");
27
27
  if (typed !== projectId) {
28
28
  console.log(`${color.red("✗")} Project name did not match — aborted.`);
@@ -53,9 +53,9 @@ function unpinIfMatches(projectId) {
53
53
  if (fs.existsSync(p)) {
54
54
  try {
55
55
  fs.unlinkSync(p);
56
- console.log(` Removed local pin at ${color.dim(p)}`);
56
+ console.log(` Removed local pin at ${color.dim(p)}`);
57
57
  } catch (err) {
58
- console.log(color.yellow(` Could not remove ${p}: ${err.message}`));
58
+ console.log(color.yellow(` Could not remove ${p}: ${err.message}`));
59
59
  }
60
60
  return;
61
61
  }
@@ -218,7 +218,7 @@ async function setEnv(projectId, pairs, opts) {
218
218
  console.log(JSON.stringify({ project_id: projectId, git_id: gitId, changed_keys: changedKeys }));
219
219
  } else {
220
220
  console.log(`${color.green("✓")} Updated env var(s) on repo ${color.dim(gitId)}: ${changedKeys.map((k) => color.bold(k)).join(", ")}`);
221
- console.log(color.dim(" (values are not shown)"));
221
+ console.log(color.dim(" (values are not shown)"));
222
222
  }
223
223
  }
224
224
 
@@ -291,12 +291,12 @@ async function listEnv(projectId, opts) {
291
291
  const appears = s.appears_in > 1
292
292
  ? color.yellow(` (appears in ${s.appears_in} files)`)
293
293
  : (s.appears_in === 1 ? color.dim(" (1 file)") : "");
294
- console.log(` - ${color.bold(s.key)}${set}${src}${appears}`);
294
+ console.log(` - ${color.bold(s.key)}${set}${src}${appears}`);
295
295
  if (s.appears_in > 1) {
296
296
  for (const c of s.candidates) {
297
297
  const shown = c.value == null ? color.dim("(empty)") : c.value;
298
298
  const marker = c.is_current ? color.green(" [current]") : "";
299
- console.log(` ${color.dim("•")} ${c.source_file}: ${shown}${marker}`);
299
+ console.log(` ${color.dim("•")} ${c.source_file}: ${shown}${marker}`);
300
300
  }
301
301
  }
302
302
  }
@@ -583,14 +583,14 @@ function maskPair(pair) {
583
583
 
584
584
  function printUsage() {
585
585
  console.error("niro edit — change an existing project. At least one action is required:");
586
- console.error(" --add-repo <gitId> map a registered repo to the project");
587
- console.error(" --remove-repo <gitId> unmap a repo (non-destructive)");
588
- console.error(" --set-env KEY=VALUE set an env var (repeatable; value never printed)");
589
- console.error(" --set-env-from KEY=FILE adopt the value FILE carries for KEY (repeatable; value never printed)");
590
- console.error(" --active-files <files> property files to keep active for env-var defaults; the rest are ignored (default: all)");
591
- console.error(" --list-env list env var keys + per-file candidates (secret values masked)");
592
- console.error(" --rescan-env rescan the repo for env vars");
593
- console.error(" --alias <newAlias> rename the project");
586
+ console.error(" --add-repo <gitId> map a registered repo to the project");
587
+ console.error(" --remove-repo <gitId> unmap a repo (non-destructive)");
588
+ console.error(" --set-env KEY=VALUE set an env var (repeatable; value never printed)");
589
+ console.error(" --set-env-from KEY=FILE adopt the value FILE carries for KEY (repeatable; value never printed)");
590
+ console.error(" --active-files <files> property files to keep active for env-var defaults; the rest are ignored (default: all)");
591
+ console.error(" --list-env list env var keys + per-file candidates (secret values masked)");
592
+ console.error(" --rescan-env rescan the repo for env vars");
593
+ console.error(" --alias <newAlias> rename the project");
594
594
  console.error("Target project: --project <alias|id>, or a .niro-project pin in this folder.");
595
595
  console.error("Env actions target --repo <gitId> (auto-selected when the project has one repo).");
596
596
  }
@@ -218,7 +218,7 @@ function printHuman(p) {
218
218
  if (proj.status) bits.push(`status: ${proj.status}`);
219
219
  if (proj.doc_gen_status) bits.push(`docs: ${proj.doc_gen_status}`);
220
220
  if (proj.updated_at) bits.push(`updated: ${proj.updated_at}`);
221
- console.log(` - ${name}${bits.length ? " — " + color.dim(bits.join(", ")) : ""}`);
221
+ console.log(` - ${name}${bits.length ? " — " + color.dim(bits.join(", ")) : ""}`);
222
222
  for (const f of proj.folders || []) {
223
223
  // Marked so the folder you are STANDING IN is obvious at a glance; the whole point is telling
224
224
  // "this worktree is the one Niro reflects" from "this one is not".
@@ -233,7 +233,7 @@ function printHuman(p) {
233
233
  temp = color.dim(` via temporary project ${f.temp_project_id}`)
234
234
  + (f.reflected === null ? color.dim(" (could not confirm)") : "");
235
235
  }
236
- console.log(color.dim(` syncing from ${f.path}`) +
236
+ console.log(color.dim(` syncing from ${f.path}`) +
237
237
  (f.branch ? color.dim(` on branch ${f.branch}`) : "") + temp + here);
238
238
  }
239
239
  }