offgrid-ai 0.7.1 → 0.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { uninstallCommand } from "./commands/uninstall.mjs";
10
10
 
11
11
  async function offerUpdate(argv) {
12
12
  const invocation = detectInvocation();
13
- const update = await checkForUpdate({ force: true });
13
+ const update = await checkForUpdate({ force: false });
14
14
  if (!update) return false;
15
15
 
16
16
  const plan = updateCommand(invocation, argv);
@@ -56,7 +56,7 @@ async function printVersion() {
56
56
  const version = currentPackageVersion();
57
57
  console.log(`offgrid-ai v${version}`);
58
58
  const invocation = detectInvocation();
59
- const update = await checkForUpdate({ force: true });
59
+ const update = await checkForUpdate({ force: false });
60
60
  if (update) {
61
61
  const plan = updateCommand(invocation, ["version"]);
62
62
  console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
@@ -9,6 +9,8 @@ import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, nor
9
9
  import { modelSelectOption, modelNameWidth, printGgufModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
10
10
  import { runProfile } from "./run.mjs";
11
11
 
12
+ const { stripVTControlCharacters } = await import("node:util");
13
+
12
14
  export async function modelsCommand(argv) {
13
15
  await ensureDirs();
14
16
  const catalog = await loadModelCatalog();
@@ -62,33 +64,64 @@ export async function modelCommandCenter(initialCatalog) {
62
64
  }
63
65
  }
64
66
 
65
-
67
+ function formatActions(rawActions) {
68
+ const sep = pc.dim(" │ ");
69
+ const maxName = Math.max(...rawActions.map((a) => stripVTControlCharacters(a.name).length));
70
+ const width = Math.max(17, maxName + 2);
71
+ return rawActions.map((a) => {
72
+ const name = a.dimmed ? pc.dim(pc.strikethrough(a.name.padEnd(width).slice(0, width))) : pc.bold(a.name.padEnd(width).slice(0, width));
73
+ const desc = a.dimmed ? pc.red("file not found") : pc.dim(a.desc);
74
+ return { value: a.value, label: name + sep + desc };
75
+ });
76
+ }
66
77
 
67
78
  function actionsForItem(item) {
79
+ const missing = item.type === "profile" && item.fileMissing;
68
80
  if (item.type === "profile") {
69
- const actions = [
70
- { value: "run", label: "Start chatting", hint: "Launch and open Pi" },
71
- { value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
72
- { value: "inspect", label: "Details", hint: "Paths, ports, flags" },
81
+ const available = [
82
+ { value: "inspect", name: "Details", desc: "Paths, ports, flags" },
73
83
  ];
74
- const backend = backendFor(item.profile.backend);
75
- if (backend.type === "local-server" || backend.type === "managed-server") actions.push({ value: "benchmark", label: "Benchmark", hint: "Prepare a benchmark run" });
76
- if (!item.fileMissing) actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
77
- return actions;
84
+ if (!missing) {
85
+ available.unshift(
86
+ { value: "run", name: "Start chatting", desc: "Launch and open Pi" },
87
+ { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings" },
88
+ );
89
+ const backend = backendFor(item.profile.backend);
90
+ if (backend.type === "local-server" || backend.type === "managed-server") {
91
+ available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run" });
92
+ }
93
+ }
94
+ available.push({ value: "remove", name: "Remove", desc: missing ? "Delete this broken setup" : "Delete this setup" });
95
+ if (missing) {
96
+ available.unshift(
97
+ { value: "run", name: "Start chatting", desc: "Launch and open Pi", dimmed: true },
98
+ { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings", dimmed: true },
99
+ );
100
+ const backend = backendFor(item.profile.backend);
101
+ if (backend.type === "local-server" || backend.type === "managed-server") {
102
+ available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run", dimmed: true });
103
+ }
104
+ }
105
+ return formatActions(available);
78
106
  }
79
107
  if (item.type === "new") {
80
- return [
81
- { value: "setup", label: "Set up", hint: "Configure and save" },
82
- { value: "inspect", label: "Details", hint: "Model info" },
83
- ];
108
+ return formatActions([
109
+ { value: "setup", name: "Set up", desc: "Configure and save" },
110
+ { value: "inspect", name: "Details", desc: "Model info" },
111
+ ]);
84
112
  }
85
- return [
86
- { value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
87
- { value: "inspect", label: "Details", hint: "Model info" },
88
- ];
113
+ return formatActions([
114
+ { value: "setup", name: "Set up", desc: `Connect via ${BACKENDS[item.backendId].label}` },
115
+ { value: "inspect", name: "Details", desc: "Model info" },
116
+ ]);
89
117
  }
90
118
 
91
119
  async function performAction(prompt, action, item) {
120
+ const missing = item.type === "profile" && item.fileMissing;
121
+ if (missing && ["run", "reconfigure", "benchmark"].includes(action)) {
122
+ console.log(pc.red("This model's file is no longer on disk. Remove the setup or move the file back."));
123
+ return;
124
+ }
92
125
  if (action === "inspect") {
93
126
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
94
127
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
@@ -169,4 +202,4 @@ async function removeProfileInteractive(id) {
169
202
  await removeFromPiConfig(profile);
170
203
  await deleteProfile(id);
171
204
  console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
172
- }
205
+ }
@@ -25,7 +25,7 @@ function optionPad(text, color, width) {
25
25
  function optionStatusTag(kind) {
26
26
  const statuses = {
27
27
  running: ["RUNNING", pc.green],
28
- ready: ["READY", pc.green],
28
+ ready: ["READY", pc.blue],
29
29
  missing: ["MISSING", pc.red],
30
30
  setup: ["SETUP", pc.yellow],
31
31
  };
@@ -130,13 +130,14 @@ export function printWorkspaceHeader(normalized, runningProfilesNow) {
130
130
  const setupCount = normalized.newModels.length + normalized.managedItems.length;
131
131
 
132
132
  const countParts = [];
133
- if (runningCount > 0) countParts.push(`${runningCount} running`);
134
- if (readyCount > 0) countParts.push(pc.green(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
133
+ if (runningCount > 0) countParts.push(pc.green(`${runningCount} running`));
134
+ if (readyCount > 0) countParts.push(pc.blue(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
135
135
  if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
136
136
  if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
137
137
 
138
- console.log(pc.dim(` ${countParts.join(" · ")}`));
138
+ console.log(` ${countParts.join(pc.dim(" · "))}`);
139
139
  console.log(pc.dim(` Profiles: ${DATA_DIR}`));
140
+ console.log(pc.dim(" ─────────────────────────────────────────────────────────"));
140
141
  }
141
142
 
142
143
  export async function printBenchmarkLine() {
@@ -155,7 +156,7 @@ export async function printProfileDetails(profile) {
155
156
  const fileMissing = !isManaged && isProfileFileMissing(profile);
156
157
  console.log("\n" + renderSection("Model overview", renderRows([
157
158
  ["Name", pc.bold(profile.label)],
158
- ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready"],
159
+ ["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : pc.blue("Ready")],
159
160
  ["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
160
161
  ["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
161
162
  ])));
package/src/updates.mjs CHANGED
@@ -15,8 +15,15 @@ export async function checkForUpdate({ now = Date.now(), fetchImpl = globalThis.
15
15
  const cacheFile = join(DATA_DIR, "update-cache.json");
16
16
  const cached = await readUpdateCache(cacheFile);
17
17
 
18
- if (!force && cached?.currentVersion === currentVersion && cached?.lastChecked && now - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
19
- return updateResult(currentVersion, cached.latestVersion);
18
+ // Use cache if fresh (within 24h) and still applies to current version
19
+ if (!force && cached?.lastChecked && now - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
20
+ if (cached.currentVersion === currentVersion) {
21
+ return updateResult(currentVersion, cached.latestVersion);
22
+ }
23
+ // Cache is from a different version — if latest isn't newer than current, no update
24
+ if (cached.latestVersion && !isNewerVersion(cached.latestVersion, currentVersion)) {
25
+ return null;
26
+ }
20
27
  }
21
28
 
22
29
  try {