@datalabrotterdam/nova-ai-cli 1.2.0-alpha.1 → 1.2.0-alpha.11

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.
@@ -1,362 +1,18 @@
1
- import {
2
- NOVA_METHODS,
3
- NOVA_NOTIFICATIONS,
4
- NovaAgent,
5
- createAgentApp,
6
- fromElicitationRequest,
7
- isInteractionMode,
8
- readSettings,
9
- resolveWorkspaceFile,
10
- toElicitationResponse
11
- } from "./chunk-GZZ7PY3S.js";
12
- import {
13
- isWorkspaceTrusted
14
- } from "./chunk-JAEXUFAS.js";
15
-
16
- // src/client/in-process.ts
17
- import * as acp from "@agentclientprotocol/sdk";
18
-
19
- // src/client/nova-agent-client.ts
20
- var NovaAgentClient = class {
21
- constructor(connection) {
22
- this.connection = connection;
23
- }
24
- connection;
25
- initialize(params) {
26
- return this.connection.initialize(params);
27
- }
28
- newSession(params) {
29
- return this.connection.newSession(params);
30
- }
31
- loadSession(params) {
32
- return this.connection.loadSession(params);
33
- }
34
- resumeSession(params) {
35
- return this.connection.resumeSession(params);
36
- }
37
- closeSession(params) {
38
- return this.connection.closeSession(params);
39
- }
40
- prompt(params) {
41
- return this.connection.prompt(params);
42
- }
43
- cancel(params) {
44
- return this.connection.cancel(params);
45
- }
46
- setSessionMode(params) {
47
- return this.connection.setSessionMode(params);
48
- }
49
- setSessionConfigOption(params) {
50
- return this.connection.setSessionConfigOption(params);
51
- }
52
- queueEnqueue(params) {
53
- return this.ext(NOVA_METHODS.queueEnqueue, params);
54
- }
55
- queueList(params) {
56
- return this.ext(NOVA_METHODS.queueList, params);
57
- }
58
- queueEditBegin(params) {
59
- return this.ext(NOVA_METHODS.queueEditBegin, params);
60
- }
61
- queueUpdate(params) {
62
- return this.ext(NOVA_METHODS.queueUpdate, params);
63
- }
64
- queueRemove(params) {
65
- return this.ext(NOVA_METHODS.queueRemove, params);
66
- }
67
- queueClear(params) {
68
- return this.ext(NOVA_METHODS.queueClear, params);
69
- }
70
- queueTakeNext(params) {
71
- return this.ext(NOVA_METHODS.queueTakeNext, params);
72
- }
73
- sessionCompact(params) {
74
- return this.ext(
75
- NOVA_METHODS.sessionCompact,
76
- params
77
- );
78
- }
79
- sessionContextUsage(params) {
80
- return this.ext(NOVA_METHODS.sessionContextUsage, params);
81
- }
82
- sessionRewind(params) {
83
- return this.ext(NOVA_METHODS.sessionRewind, params);
84
- }
85
- backgroundStartTerminal(params) {
86
- return this.ext(NOVA_METHODS.backgroundStartTerminal, params);
87
- }
88
- backgroundStartPrompt(params) {
89
- return this.ext(NOVA_METHODS.backgroundStartPrompt, params);
90
- }
91
- backgroundList(params) {
92
- return this.ext(NOVA_METHODS.backgroundList, params);
93
- }
94
- backgroundOutput(params) {
95
- return this.ext(NOVA_METHODS.backgroundOutput, params);
96
- }
97
- backgroundKill(params) {
98
- return this.ext(NOVA_METHODS.backgroundKill, params);
99
- }
100
- backgroundRelease(params) {
101
- return this.ext(NOVA_METHODS.backgroundRelease, params);
102
- }
103
- async ext(method, params) {
104
- return await this.connection.extMethod(method, params);
105
- }
106
- };
107
-
108
- // src/client/in-process.ts
109
- function startInProcessAgent(toClient, agent = new NovaAgent()) {
110
- const toAgent = new TransformStream();
111
- const toClientStream = new TransformStream();
112
- const agentConnection = createAgentApp(agent).connect(
113
- acp.ndJsonStream(toClientStream.writable, toAgent.readable)
114
- );
115
- const connection = new acp.ClientSideConnection(
116
- toClient,
117
- acp.ndJsonStream(toAgent.writable, toClientStream.readable)
118
- );
119
- return {
120
- agent,
121
- client: new NovaAgentClient(connection),
122
- async close() {
123
- await agent.shutdown().catch(() => {
124
- });
125
- agentConnection.close();
126
- }
127
- };
128
- }
129
-
130
- // src/tui/settings/workspace-mcp.ts
131
- import { readFileSync as readFileSync2 } from "fs";
132
- import { join as join2 } from "path";
133
-
134
- // src/tui/settings/workspace-settings.ts
135
- import { readFileSync } from "fs";
136
- import { join } from "path";
137
- function settingsPath(cwd) {
138
- return join(cwd, ".nova-ai", "settings.json");
139
- }
140
- function readWorkspaceSettings(cwd) {
141
- try {
142
- const raw = readFileSync(settingsPath(cwd), "utf8");
143
- return JSON.parse(raw);
144
- } catch {
145
- return {};
146
- }
147
- }
148
-
149
- // src/tui/settings/workspace-mcp.ts
150
- var UNTRUSTED_MCP_MESSAGE = "Not started: MCP servers declared by this workspace only run once you trust it (/trust in the TUI, --trust-workspace headless).";
151
- function readWorkspaceMcpConfiguration(cwd, options = {}) {
152
- const configured = readWorkspaceSettings(cwd).mcpServers ?? [];
153
- const project = readProjectMcpConfiguration(cwd);
154
- const servers = new Map(configured.map((server) => [server.name, server]));
155
- for (const server of project.servers) servers.set(server.name, server);
156
- const declared = [...servers.values()];
157
- if (declared.length && !(options.trusted ?? isWorkspaceTrusted(cwd))) {
158
- return {
159
- servers: [],
160
- failures: [
161
- ...project.failures,
162
- ...declared.map((server) => ({ serverName: server.name, message: UNTRUSTED_MCP_MESSAGE }))
163
- ],
164
- untrusted: declared.map((server) => server.name)
165
- };
166
- }
167
- return { servers: declared, failures: project.failures };
168
- }
169
- function workspaceNeedsTrust(cwd) {
170
- if (isWorkspaceTrusted(cwd)) return false;
171
- if (readWorkspaceMcpConfiguration(cwd, { trusted: true }).servers.length) return true;
172
- return loadWorkspaceAllowRules(cwd).length > 0;
173
- }
174
- function loadWorkspaceAllowRules(cwd) {
175
- return ["settings.json", "settings.local.json"].flatMap((name) => {
176
- const allow = readSettings(join2(cwd, ".nova-ai", name))?.permissions?.allow;
177
- return Array.isArray(allow) ? allow : [];
178
- });
179
- }
180
- function readProjectMcpConfiguration(cwd) {
181
- const path = join2(cwd, ".mcp.json");
182
- let raw;
183
- try {
184
- raw = readFileSync2(path, "utf8");
185
- } catch (error) {
186
- if (error.code === "ENOENT") return { servers: [], failures: [] };
187
- return { servers: [], failures: [{ serverName: ".mcp.json", message: errorMessage(error, "Could not read file") }] };
188
- }
189
- let document;
190
- try {
191
- document = JSON.parse(raw);
192
- } catch (error) {
193
- return { servers: [], failures: [{ serverName: ".mcp.json", message: errorMessage(error, "Invalid JSON") }] };
194
- }
195
- const root = asRecord(document);
196
- const definitions = root ? asRecord(root.mcpServers) : null;
197
- if (!definitions) {
198
- return {
199
- servers: [],
200
- failures: [{ serverName: ".mcp.json", message: 'Expected an "mcpServers" object.' }]
201
- };
202
- }
203
- const servers = [];
204
- const failures = [];
205
- for (const [name, value] of Object.entries(definitions)) {
206
- const config = asRecord(value);
207
- if (config?.disabled === true || config?.enabled === false) continue;
208
- try {
209
- servers.push(normalizeProjectServer(name, config));
210
- } catch (error) {
211
- failures.push({ serverName: `.mcp.json:${name}`, message: errorMessage(error, "Invalid server configuration") });
212
- }
213
- }
214
- return { servers, failures };
215
- }
216
- function normalizeProjectServer(name, config) {
217
- if (!config) throw new Error("Server definition must be an object.");
218
- const type = typeof config.type === "string" ? config.type : config.url ? "http" : "stdio";
219
- if (type === "http" || type === "sse") {
220
- if (typeof config.url !== "string" || !config.url) throw new Error(`${type} server requires a URL.`);
221
- try {
222
- new URL(config.url);
223
- } catch {
224
- throw new Error(`${type} server requires a valid URL.`);
225
- }
226
- return {
227
- name,
228
- type,
229
- url: config.url,
230
- headers: normalizePairs(config.headers, "headers")
231
- };
232
- }
233
- if (type !== "stdio") throw new Error(`Unsupported transport type: ${type}.`);
234
- if (typeof config.command !== "string" || !config.command) throw new Error("stdio server requires a command.");
235
- if (config.args !== void 0 && (!Array.isArray(config.args) || !config.args.every((arg) => typeof arg === "string"))) {
236
- throw new Error("args must be an array of strings.");
237
- }
238
- return {
239
- name,
240
- command: config.command,
241
- args: config.args ?? [],
242
- env: normalizePairs(config.env, "env")
243
- };
244
- }
245
- function normalizePairs(value, label) {
246
- if (value === void 0) return [];
247
- if (Array.isArray(value)) {
248
- if (!value.every((entry) => {
249
- const pair = asRecord(entry);
250
- return typeof pair?.name === "string" && typeof pair.value === "string";
251
- })) {
252
- throw new Error(`${label} must contain name/value strings.`);
253
- }
254
- return value.map((entry) => {
255
- const pair = entry;
256
- return { name: pair.name, value: pair.value };
257
- });
258
- }
259
- const record = asRecord(value);
260
- if (!record || !Object.values(record).every((entry) => typeof entry === "string")) {
261
- throw new Error(`${label} must be an object of string values.`);
262
- }
263
- return Object.entries(record).map(([name, entry]) => ({ name, value: entry }));
264
- }
265
- function asRecord(value) {
266
- return value && typeof value === "object" && !Array.isArray(value) ? value : null;
267
- }
268
- function errorMessage(error, fallback) {
269
- return error instanceof Error && error.message ? `${fallback}: ${error.message}` : fallback;
270
- }
271
-
272
1
  // src/tui/session/tui-acp-client.ts
2
+ import { NOVA_NOTIFICATIONS } from "@datalabrotterdam/nova-ai-agent/acp/extensions.js";
3
+ import { openBrowser } from "@datalabrotterdam/nova-ai-agent/acp/auth-server.js";
273
4
  import { readFile, writeFile, mkdir } from "fs/promises";
274
5
  import { dirname } from "path";
275
-
276
- // src/core/process-tree.ts
277
- import { spawn, spawnSync } from "child_process";
278
- var KILL_GRACE_MS = 1e3;
279
- var liveChildren = /* @__PURE__ */ new Set();
280
- var exitHookInstalled = false;
281
- function spawnCommand(command, options) {
282
- installExitHook();
283
- const common = {
284
- cwd: options.cwd,
285
- env: options.env ?? process.env,
286
- detached: process.platform !== "win32",
287
- windowsHide: true,
288
- stdio: ["ignore", "pipe", "pipe"]
289
- };
290
- const child = options.args?.length ? spawn(command, options.args, common) : spawn(command, { ...common, shell: true });
291
- liveChildren.add(child);
292
- const forget = () => liveChildren.delete(child);
293
- child.once("exit", forget);
294
- child.once("error", forget);
295
- return child;
296
- }
297
- function killProcessTree(child, signal = "SIGTERM") {
298
- if (child.pid === void 0) return;
299
- try {
300
- if (process.platform === "win32") {
301
- spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
302
- stdio: "ignore",
303
- windowsHide: true
304
- }).on("error", () => {
305
- });
306
- } else {
307
- process.kill(-child.pid, signal);
308
- }
309
- } catch {
310
- try {
311
- child.kill(signal);
312
- } catch {
313
- }
314
- }
315
- }
316
- function terminateProcessTree(child, graceMs = KILL_GRACE_MS) {
317
- if (hasExited(child)) return Promise.resolve();
318
- return new Promise((resolve) => {
319
- let forceTimer;
320
- let giveUpTimer;
321
- const done = () => {
322
- clearTimeout(forceTimer);
323
- clearTimeout(giveUpTimer);
324
- resolve();
325
- };
326
- child.once("exit", done);
327
- killProcessTree(child, "SIGTERM");
328
- forceTimer = setTimeout(() => {
329
- killProcessTree(child, "SIGKILL");
330
- giveUpTimer = setTimeout(done, graceMs);
331
- }, graceMs);
332
- });
333
- }
334
- function hasExited(child) {
335
- return child.exitCode !== null || child.signalCode !== null;
336
- }
337
- function installExitHook() {
338
- if (exitHookInstalled) return;
339
- exitHookInstalled = true;
340
- process.once("exit", () => {
341
- for (const child of liveChildren) {
342
- if (process.platform === "win32" && child.pid !== void 0) {
343
- try {
344
- spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
345
- stdio: "ignore",
346
- windowsHide: true,
347
- timeout: 2e3
348
- });
349
- } catch {
350
- }
351
- } else {
352
- killProcessTree(child, "SIGKILL");
353
- }
354
- }
355
- liveChildren.clear();
356
- });
357
- }
358
-
359
- // src/tui/session/tui-acp-client.ts
6
+ import {
7
+ spawnCommand,
8
+ terminateProcessTree
9
+ } from "@datalabrotterdam/nova-ai-agent/core/process-tree.js";
10
+ import { resolveWorkspaceFile } from "@datalabrotterdam/nova-ai-agent/core/tools/workspace-paths.js";
11
+ import {
12
+ fromElicitationRequest,
13
+ toElicitationResponse
14
+ } from "@datalabrotterdam/nova-ai-agent/acp/user-questions.js";
15
+ import { INTERACTION_MODES } from "@datalabrotterdam/nova-ai-agent/core/interaction-modes.js";
360
16
  var DEFAULT_OUTPUT_LIMIT = 1e5;
361
17
  var nextMessageId = 0;
362
18
  function uid() {
@@ -374,6 +30,10 @@ var TuiAcpClient = class {
374
30
  cwd;
375
31
  onBackgroundLifecycle;
376
32
  onInteractionModeChange;
33
+ /** The modes the agent offers (built-in, then the user's agent files); set by the runner. */
34
+ modeIds = [...INTERACTION_MODES];
35
+ /** Slash commands the agent understands, such as the user's command files. */
36
+ agentCommands = [];
377
37
  capabilities = {
378
38
  fs: { readTextFile: true, writeTextFile: true },
379
39
  terminal: true,
@@ -434,6 +94,29 @@ var TuiAcpClient = class {
434
94
  appendUserMessage(text) {
435
95
  this.appendMessage({ id: uid(), role: "user", text });
436
96
  }
97
+ /** An MCP server's sign-in page: opened in the browser, and printed in case that fails. */
98
+ mcpAuthUrl(params) {
99
+ const { serverName, url } = params ?? {};
100
+ if (typeof serverName !== "string" || typeof url !== "string") return;
101
+ let page;
102
+ try {
103
+ page = new URL(url);
104
+ } catch {
105
+ return;
106
+ }
107
+ if (page.protocol !== "https:" && page.protocol !== "http:") {
108
+ this.appendError(`MCP server "${serverName}" sent a sign-in address that is not a web page; not opened.`);
109
+ return;
110
+ }
111
+ openBrowser(page.toString());
112
+ this.appendMessage({
113
+ id: uid(),
114
+ role: "assistant",
115
+ streaming: false,
116
+ text: `Sign in to MCP server "${serverName}" in your browser. If it did not open, visit:
117
+ ${page}`
118
+ });
119
+ }
437
120
  appendError(text) {
438
121
  this.appendMessage({ id: uid(), role: "error", text });
439
122
  }
@@ -637,8 +320,15 @@ ${err.message}`;
637
320
  });
638
321
  }
639
322
  return;
323
+ case "available_commands_update":
324
+ this.agentCommands = update.availableCommands.map(({ name, description, input }) => ({
325
+ name,
326
+ description,
327
+ ...input && "hint" in input && input.hint ? { hint: input.hint } : {}
328
+ }));
329
+ return;
640
330
  case "current_mode_update":
641
- if (isInteractionMode(update.currentModeId)) {
331
+ if (this.modeIds.includes(update.currentModeId)) {
642
332
  this.onInteractionModeChange?.(update.currentModeId);
643
333
  }
644
334
  return;
@@ -758,6 +448,8 @@ ${err.message}`;
758
448
  return this.backgroundUpdate(params);
759
449
  case NOVA_NOTIFICATIONS.queueChanged:
760
450
  return this.queueChanged(params);
451
+ case NOVA_NOTIFICATIONS.mcpAuthUrl:
452
+ return this.mcpAuthUrl(params);
761
453
  default:
762
454
  return;
763
455
  }
@@ -945,9 +637,6 @@ function createStore(initial) {
945
637
  }
946
638
 
947
639
  export {
948
- startInProcessAgent,
949
- readWorkspaceMcpConfiguration,
950
- workspaceNeedsTrust,
951
640
  TuiAcpClient,
952
641
  choosePermissionOption,
953
642
  createStore
@@ -0,0 +1,170 @@
1
+ import {
2
+ CLI_PACKAGE_NAME,
3
+ cliVersion
4
+ } from "./chunk-FMHWW3E6.js";
5
+
6
+ // src/update-check.ts
7
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ import { novaHomeRoot } from "@datalabrotterdam/nova-ai-agent/core/nova-home.js";
10
+ var DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${CLI_PACKAGE_NAME.replace("/", "%2F")}/dist-tags`;
11
+ var DEFAULT_TIMEOUT_MS = 3e3;
12
+ var CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
13
+ function updateCachePath() {
14
+ return join(novaHomeRoot(), "update-check.json");
15
+ }
16
+ function updateChecksDisabled(env = process.env) {
17
+ return Boolean(env.NOVA_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
18
+ }
19
+ async function checkForUpdate(options = {}) {
20
+ try {
21
+ if (updateChecksDisabled(options.env)) return null;
22
+ const currentVersion = options.currentVersion ?? cliVersion();
23
+ if (!currentVersion) return null;
24
+ const distTags = await readDistTags(options);
25
+ if (!distTags) return null;
26
+ return chooseUpdate(currentVersion, distTags);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function chooseUpdate(currentVersion, distTags) {
32
+ const channel = prereleaseChannel(currentVersion);
33
+ const tags = channel && distTags[channel] ? [channel, "latest"] : ["latest"];
34
+ let best = null;
35
+ for (const tag of tags) {
36
+ const version = distTags[tag];
37
+ if (!version || !isNewerVersion(version, currentVersion)) continue;
38
+ if (!best || isNewerVersion(version, best.version)) best = { tag, version };
39
+ }
40
+ if (!best) return null;
41
+ return {
42
+ currentVersion,
43
+ latestVersion: best.version,
44
+ tag: best.tag,
45
+ command: `npm install -g ${CLI_PACKAGE_NAME}@${best.tag}`
46
+ };
47
+ }
48
+ async function readDistTags(options) {
49
+ const now = (options.now ?? Date.now)();
50
+ const cachePath = options.cachePath === void 0 ? updateCachePath() : options.cachePath;
51
+ const cached = cachePath ? readCache(cachePath) : null;
52
+ if (cached && now - cached.checkedAt >= 0 && now - cached.checkedAt < CACHE_MAX_AGE_MS) {
53
+ return cached.distTags;
54
+ }
55
+ const response = await (options.fetchImpl ?? fetch)(DIST_TAGS_URL, {
56
+ headers: { accept: "application/json" },
57
+ signal: AbortSignal.any([
58
+ AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
59
+ ...options.signal ? [options.signal] : []
60
+ ])
61
+ });
62
+ if (!response.ok) return null;
63
+ const payload = await response.json();
64
+ if (!isDistTags(payload)) return null;
65
+ if (cachePath) writeCache(cachePath, { ...cached, checkedAt: now, distTags: payload });
66
+ return payload;
67
+ }
68
+ function readCache(path) {
69
+ try {
70
+ const value = JSON.parse(readFileSync(path, "utf8"));
71
+ if (value && typeof value === "object" && typeof value.checkedAt === "number" && isDistTags(value.distTags)) {
72
+ return value;
73
+ }
74
+ } catch {
75
+ }
76
+ return null;
77
+ }
78
+ var SNOOZE_MS = 24 * 60 * 60 * 1e3;
79
+ function shouldAskAboutUpdate(update, options = {}) {
80
+ const cachePath = options.cachePath === void 0 ? updateCachePath() : options.cachePath;
81
+ const cached = cachePath ? readCache(cachePath) : null;
82
+ if (!cached) return true;
83
+ if (cached.skippedVersion === update.latestVersion) return false;
84
+ const now = (options.now ?? Date.now)();
85
+ return !(typeof cached.snoozedUntil === "number" && now < cached.snoozedUntil);
86
+ }
87
+ function rememberUpdateAnswer(answer, update, options = {}) {
88
+ const cachePath = options.cachePath === void 0 ? updateCachePath() : options.cachePath;
89
+ if (!cachePath) return;
90
+ const cached = readCache(cachePath);
91
+ const base = cached ?? { checkedAt: 0, distTags: {} };
92
+ const now = (options.now ?? Date.now)();
93
+ writeCache(
94
+ cachePath,
95
+ answer === "skip" ? { ...base, skippedVersion: update.latestVersion, snoozedUntil: void 0 } : { ...base, snoozedUntil: now + SNOOZE_MS }
96
+ );
97
+ }
98
+ function writeCache(path, value) {
99
+ try {
100
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
101
+ writeFileSync(path, `${JSON.stringify(value)}
102
+ `, { mode: 384 });
103
+ } catch {
104
+ }
105
+ }
106
+ function isDistTags(value) {
107
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((version) => typeof version === "string");
108
+ }
109
+ function prereleaseChannel(version) {
110
+ const parsed = parseVersion(version);
111
+ const first = parsed?.prerelease[0];
112
+ return first && !/^\d+$/.test(first) ? first : null;
113
+ }
114
+ function readInstalledVersion() {
115
+ return cliVersion();
116
+ }
117
+ function isNewerVersion(candidate, current) {
118
+ const next = parseVersion(candidate);
119
+ const installed = parseVersion(current);
120
+ if (!next || !installed) return false;
121
+ for (let index = 0; index < 3; index++) {
122
+ const difference = next.core[index] - installed.core[index];
123
+ if (difference !== 0) return difference > 0;
124
+ }
125
+ return comparePrerelease(next.prerelease, installed.prerelease) > 0;
126
+ }
127
+ function parseVersion(value) {
128
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
129
+ if (!match) return null;
130
+ return {
131
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
132
+ prerelease: match[4]?.split(".") ?? []
133
+ };
134
+ }
135
+ function comparePrerelease(left, right) {
136
+ if (left.length === 0 || right.length === 0) {
137
+ if (left.length === right.length) return 0;
138
+ return left.length === 0 ? 1 : -1;
139
+ }
140
+ const length = Math.max(left.length, right.length);
141
+ for (let index = 0; index < length; index++) {
142
+ const leftPart = left[index];
143
+ const rightPart = right[index];
144
+ if (leftPart === void 0 || rightPart === void 0) {
145
+ return leftPart === rightPart ? 0 : leftPart === void 0 ? -1 : 1;
146
+ }
147
+ if (leftPart === rightPart) continue;
148
+ const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
149
+ const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
150
+ if (leftNumber !== null && rightNumber !== null) return leftNumber - rightNumber;
151
+ if (leftNumber !== null || rightNumber !== null) return leftNumber !== null ? -1 : 1;
152
+ return leftPart < rightPart ? -1 : 1;
153
+ }
154
+ return 0;
155
+ }
156
+ function formatUpdateNotice(update) {
157
+ return `Update available: nova-ai ${update.currentVersion} \u2192 ${update.latestVersion}. Run: ${update.command}`;
158
+ }
159
+
160
+ export {
161
+ updateCachePath,
162
+ updateChecksDisabled,
163
+ checkForUpdate,
164
+ chooseUpdate,
165
+ shouldAskAboutUpdate,
166
+ rememberUpdateAnswer,
167
+ readInstalledVersion,
168
+ isNewerVersion,
169
+ formatUpdateNotice
170
+ };