offgrid-ai 0.5.0 → 0.5.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +39 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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
@@ -172,54 +172,51 @@ async function modelsCommand(argv) {
172
172
  return await modelCommandCenter(catalog);
173
173
  }
174
174
 
175
- async function modelCommandCenter(catalog) {
176
- const normalized = normalizeCatalog(catalog);
177
- const { profiles } = normalized;
178
- const runningProfilesNow = [];
179
- for (const profile of profiles) {
180
- if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
175
+ async function modelCommandCenter(initialCatalog) {
176
+ if (!process.stdin.isTTY) {
177
+ const normalized = normalizeCatalog(initialCatalog);
178
+ const allItems = buildCatalogItems(normalized);
179
+ if (allItems.length === 0) return;
180
+ // Non-interactive: just show summary
181
+ for (const item of allItems) console.log(item.label);
182
+ return;
181
183
  }
182
- const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
183
-
184
- // Summary card
185
- const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
186
- console.log("\n" + renderCard("Your local AI workspace", renderRows([
187
- ["Setups", `${profiles.length} saved`],
188
- ["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
189
- ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
190
- ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
191
- ]), { formatBorder: summaryBorder }));
192
-
193
- if (!process.stdin.isTTY) return;
194
- const allItems = buildCatalogItems(normalized);
195
- if (allItems.length === 0) return;
196
184
 
197
185
  const prompt = createPrompt();
198
186
  try {
199
187
  while (true) {
200
- // Build model select options
188
+ // Reload catalog each loop to reflect setup/remove changes
189
+ const catalog = await loadModelCatalog();
190
+ const normalized = normalizeCatalog(catalog);
191
+ const allItems = buildCatalogItems(normalized);
192
+ if (allItems.length === 0) { console.log(pc.dim("No models found.")); break; }
193
+ const runningProfilesNow = [];
194
+ for (const profile of normalized.profiles) {
195
+ if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
196
+ }
197
+ const fileMissingCount = normalized.profiles.filter((p) => isProfileFileMissing(p)).length;
198
+ const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
199
+ console.log("\n" + renderCard("Your local AI workspace", renderRows([
200
+ ["Setups", `${normalized.profiles.length} saved`],
201
+ ["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
202
+ ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
203
+ ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
204
+ ]), { formatBorder: summaryBorder }));
205
+
201
206
  const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
202
207
  const selected = await prompt.choice("Select a model", options);
203
208
  if (!selected) break;
204
209
  const item = allItems.find((i) => itemKey(i) === selected);
205
210
  if (!item) break;
206
211
 
207
- // Build contextual actions
208
212
  const actions = actionsForItem(item);
209
213
  if (actions.length === 1) {
210
- // Only one action — just do it
211
214
  await performAction(prompt, actions[0].value, item);
212
215
  } else {
213
216
  const action = await prompt.choice(item.label, actions, actions[0].value);
214
217
  if (!action) continue;
215
- if (action === "back") continue;
216
218
  await performAction(prompt, action, item);
217
219
  }
218
- // After action, refresh running state and loop back
219
- runningProfilesNow.length = 0;
220
- for (const profile of profiles) {
221
- if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
222
- }
223
220
  }
224
221
  } finally {
225
222
  prompt.close();
@@ -287,29 +284,27 @@ function modelSelectOption(item, { runningProfilesNow }) {
287
284
  const fileMissing = item.fileMissing;
288
285
  const caps = profile.capabilities ?? {};
289
286
  let status;
290
- if (fileMissing) status = pc.red("File missing");
291
- else if (running) status = pc.green("Running");
292
- else status = pc.dim("Ready");
293
- const detailParts = [status];
294
- if (humanCapabilitySummary(caps)) detailParts.push(humanCapabilitySummary(caps));
295
- if (profile.drafterPath) detailParts.push(pc.green("MTP"));
296
- if (profile.flags?.ctxSize) detailParts.push(`${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx`);
297
- return { value: itemKey(item), label: profile.label, hint: detailParts.join(pc.dim(" · ")) };
287
+ if (fileMissing) status = pc.red("File missing");
288
+ else if (running) status = pc.green("Running");
289
+ else status = pc.dim("Ready");
290
+ const labelParts = [profile.label, status];
291
+ if (profile.drafterPath) labelParts.push(pc.green("MTP"));
292
+ if (profile.flags?.ctxSize) labelParts.push(`${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx`);
293
+ return { value: itemKey(item), label: labelParts.join(" "), hint: humanCapabilitySummary(caps) || profile.modelAlias };
298
294
  }
299
295
  if (item.type === "new") {
300
296
  const { model, drafter } = item;
301
297
  const caps = detectCapabilities(model.path, model.mmprojPath);
302
- const detailParts = [pc.yellow("Needs setup")];
303
- if (humanCapabilitySummary(caps)) detailParts.push(humanCapabilitySummary(caps));
304
- if (caps.mtp || drafter) detailParts.push(pc.green("MTP"));
305
- else if (caps.architecture === "gemma4") detailParts.push(pc.yellow("MTP: needs drafter"));
306
- detailParts.push(formatBytes(model.sizeBytes));
307
- return { value: itemKey(item), label: model.label, hint: detailParts.join(pc.dim(" · ")) };
298
+ const labelParts = [model.label, pc.yellow("Needs setup")];
299
+ if (caps.mtp || drafter) labelParts.push(pc.green("MTP ✓"));
300
+ else if (caps.architecture === "gemma4") labelParts.push(pc.yellow("MTP"));
301
+ labelParts.push(formatBytes(model.sizeBytes));
302
+ return { value: itemKey(item), label: labelParts.join(" "), hint: humanCapabilitySummary(caps) || model.quant };
308
303
  }
309
304
  // managed
310
305
  const { model, backendId } = item;
311
306
  const backend = BACKENDS[backendId];
312
- return { value: itemKey(item), label: model.label, hint: `${backend.label} · ${model.id}` };
307
+ return { value: itemKey(item), label: `${model.label} ${pc.dim(`via ${backend.label}`)}`, hint: model.id };
313
308
  }
314
309
 
315
310
  function actionsForItem(item) {