offgrid-ai 0.5.1 → 0.5.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +92 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.5.1",
3
+ "version": "0.5.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
@@ -177,7 +177,6 @@ async function modelCommandCenter(initialCatalog) {
177
177
  const normalized = normalizeCatalog(initialCatalog);
178
178
  const allItems = buildCatalogItems(normalized);
179
179
  if (allItems.length === 0) return;
180
- // Non-interactive: just show summary
181
180
  for (const item of allItems) console.log(item.label);
182
181
  return;
183
182
  }
@@ -185,7 +184,6 @@ async function modelCommandCenter(initialCatalog) {
185
184
  const prompt = createPrompt();
186
185
  try {
187
186
  while (true) {
188
- // Reload catalog each loop to reflect setup/remove changes
189
187
  const catalog = await loadModelCatalog();
190
188
  const normalized = normalizeCatalog(catalog);
191
189
  const allItems = buildCatalogItems(normalized);
@@ -203,6 +201,9 @@ async function modelCommandCenter(initialCatalog) {
203
201
  ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
204
202
  ]), { formatBorder: summaryBorder }));
205
203
 
204
+ // Show model cards for all items
205
+ printModelCards(allItems, { runningProfilesNow, drafters: normalized.drafters });
206
+
206
207
  const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
207
208
  const selected = await prompt.choice("Select a model", options);
208
209
  if (!selected) break;
@@ -210,13 +211,9 @@ async function modelCommandCenter(initialCatalog) {
210
211
  if (!item) break;
211
212
 
212
213
  const actions = actionsForItem(item);
213
- if (actions.length === 1) {
214
- await performAction(prompt, actions[0].value, item);
215
- } else {
216
- const action = await prompt.choice(item.label, actions, actions[0].value);
217
- if (!action) continue;
218
- await performAction(prompt, action, item);
219
- }
214
+ const action = await prompt.choice(item.label, actions, actions[0].value);
215
+ if (!action) continue;
216
+ await performAction(prompt, action, item);
220
217
  }
221
218
  } finally {
222
219
  prompt.close();
@@ -282,24 +279,27 @@ function modelSelectOption(item, { runningProfilesNow }) {
282
279
  const { profile } = item;
283
280
  const running = runningProfilesNow.some((r) => r.id === profile.id);
284
281
  const fileMissing = item.fileMissing;
285
- const caps = profile.capabilities ?? {};
286
282
  let status;
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 };
283
+ if (fileMissing) status = pc.red("⚠ missing");
284
+ else if (running) status = pc.green("● running");
285
+ else status = pc.dim("● ready");
286
+ // MTP label: enabled vs available vs not available
287
+ let mtpLabel;
288
+ if (profile.drafterPath) mtpLabel = pc.green("MTP");
289
+ else if ((profile.capabilities?.architecture === "gemma4")) mtpLabel = pc.yellow("MTP available");
290
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k` : null;
291
+ const label = [profile.label, status, mtpLabel, ctxLabel].filter(Boolean).join(" ");
292
+ return { value: itemKey(item), label, hint: humanCapabilitySummary(profile.capabilities ?? {}) || profile.modelAlias };
294
293
  }
295
294
  if (item.type === "new") {
296
295
  const { model, drafter } = item;
297
296
  const caps = detectCapabilities(model.path, model.mmprojPath);
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 };
297
+ const mtpAvailable = caps.mtp || Boolean(drafter);
298
+ let mtpLabel;
299
+ if (mtpAvailable) mtpLabel = pc.green("MTP");
300
+ else if (caps.architecture === "gemma4") mtpLabel = pc.yellow("MTP");
301
+ const label = [model.label, pc.yellow("needs setup"), mtpLabel, formatBytes(model.sizeBytes)].filter(Boolean).join(" ");
302
+ return { value: itemKey(item), label, hint: humanCapabilitySummary(caps) || model.quant };
303
303
  }
304
304
  // managed
305
305
  const { model, backendId } = item;
@@ -339,6 +339,76 @@ function isProfileFileMissing(profile) {
339
339
  return !existsSync(profile.modelPath);
340
340
  }
341
341
 
342
+ function printModelCards(items, { runningProfilesNow, drafters }) {
343
+ // Group items by type for section headers
344
+ const profiles = items.filter((i) => i.type === "profile");
345
+ const newModels = items.filter((i) => i.type === "new");
346
+ const managed = items.filter((i) => i.type === "managed");
347
+
348
+ if (profiles.length > 0) {
349
+ console.log("\n" + pc.bold("Ready to chat"));
350
+ for (const item of profiles) {
351
+ const { profile } = item;
352
+ const backend = backendFor(profile.backend);
353
+ const running = runningProfilesNow.some((r) => r.id === profile.id);
354
+ const fileMissing = item.fileMissing;
355
+ const caps = profile.capabilities ?? {};
356
+ let status;
357
+ if (fileMissing) status = pc.red("File missing");
358
+ else if (running) status = pc.green("Running now");
359
+ else status = "Ready";
360
+ const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
361
+ // MTP: enabled vs available
362
+ let mtpLabel;
363
+ if (profile.drafterPath) mtpLabel = pc.green("MTP enabled");
364
+ else if (drafters ? matchDrafter(profile.modelPath, drafters) : null) mtpLabel = pc.yellow("MTP available");
365
+ else if (caps.architecture === "gemma4") mtpLabel = pc.yellow("MTP: needs drafter");
366
+ const detailParts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps)];
367
+ if (mtpLabel) detailParts.push(mtpLabel);
368
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
369
+ if (ctxLabel) detailParts.push(ctxLabel);
370
+ console.log(renderCard(profile.label, renderRows([
371
+ ["Status", status],
372
+ ["Details", detailParts.join(pc.dim(" · "))],
373
+ ["Runs with", backend.label],
374
+ ]), { formatBorder: border }));
375
+ }
376
+ }
377
+
378
+ if (newModels.length > 0) {
379
+ console.log("\n" + pc.bold("Needs setup"));
380
+ for (const item of newModels) {
381
+ const { model, drafter } = item;
382
+ const caps = detectCapabilities(model.path, model.mmprojPath);
383
+ const mtpAvailable = caps.mtp || Boolean(drafter);
384
+ const mtpLabel = mtpAvailable
385
+ ? pc.green("MTP ✓")
386
+ : (caps.architecture === "gemma4")
387
+ ? pc.yellow("MTP: needs drafter")
388
+ : null;
389
+ const detailParts = [humanCapabilitySummary(caps)];
390
+ if (mtpLabel) detailParts.push(mtpLabel);
391
+ detailParts.push(formatBytes(model.sizeBytes));
392
+ console.log(renderCard(model.label, renderRows([
393
+ ["Status", pc.yellow("Needs setup")],
394
+ ["Details", detailParts.join(pc.dim(" · "))],
395
+ ]), { formatBorder: pc.yellow }));
396
+ }
397
+ }
398
+
399
+ for (const beId of ["ollama", "omlx"]) {
400
+ const managedForBackend = managed.filter((i) => i.backendId === beId);
401
+ if (managedForBackend.length === 0) continue;
402
+ const be = BACKENDS[beId];
403
+ console.log("\n" + pc.bold(`Via ${be.label}`));
404
+ for (const item of managedForBackend) {
405
+ console.log(renderCard(item.model.label, renderRows([
406
+ ["Details", [item.model.id, item.model.quant].filter(Boolean).join(pc.dim(" · "))],
407
+ ]), { formatBorder: pc.dim }));
408
+ }
409
+ }
410
+ }
411
+
342
412
  async function performAction(prompt, action, item) {
343
413
  if (action === "inspect") {
344
414
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));