offgrid-ai 0.5.1 → 0.5.2

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 +85 -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.2",
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);
@@ -209,14 +207,14 @@ async function modelCommandCenter(initialCatalog) {
209
207
  const item = allItems.find((i) => itemKey(i) === selected);
210
208
  if (!item) break;
211
209
 
210
+ // Show detail card before action menu
211
+ console.log("");
212
+ printItemCard(item, { runningProfilesNow, drafters: normalized.drafters });
213
+
212
214
  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
- }
215
+ const action = await prompt.choice(item.label, actions, actions[0].value);
216
+ if (!action) continue;
217
+ await performAction(prompt, action, item);
220
218
  }
221
219
  } finally {
222
220
  prompt.close();
@@ -282,24 +280,27 @@ function modelSelectOption(item, { runningProfilesNow }) {
282
280
  const { profile } = item;
283
281
  const running = runningProfilesNow.some((r) => r.id === profile.id);
284
282
  const fileMissing = item.fileMissing;
285
- const caps = profile.capabilities ?? {};
286
283
  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 };
284
+ if (fileMissing) status = pc.red("⚠ missing");
285
+ else if (running) status = pc.green("● running");
286
+ else status = pc.dim("● ready");
287
+ // MTP label: enabled vs available vs not available
288
+ let mtpLabel;
289
+ if (profile.drafterPath) mtpLabel = pc.green("MTP");
290
+ else if ((profile.capabilities?.architecture === "gemma4")) mtpLabel = pc.yellow("MTP available");
291
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k` : null;
292
+ const label = [profile.label, status, mtpLabel, ctxLabel].filter(Boolean).join(" ");
293
+ return { value: itemKey(item), label, hint: humanCapabilitySummary(profile.capabilities ?? {}) || profile.modelAlias };
294
294
  }
295
295
  if (item.type === "new") {
296
296
  const { model, drafter } = item;
297
297
  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 };
298
+ const mtpAvailable = caps.mtp || Boolean(drafter);
299
+ let mtpLabel;
300
+ if (mtpAvailable) mtpLabel = pc.green("MTP");
301
+ else if (caps.architecture === "gemma4") mtpLabel = pc.yellow("MTP");
302
+ const label = [model.label, pc.yellow("needs setup"), mtpLabel, formatBytes(model.sizeBytes)].filter(Boolean).join(" ");
303
+ return { value: itemKey(item), label, hint: humanCapabilitySummary(caps) || model.quant };
303
304
  }
304
305
  // managed
305
306
  const { model, backendId } = item;
@@ -339,6 +340,68 @@ function isProfileFileMissing(profile) {
339
340
  return !existsSync(profile.modelPath);
340
341
  }
341
342
 
343
+ function printItemCard(item, { runningProfilesNow, drafters }) {
344
+ if (item.type === "profile") {
345
+ const { profile } = item;
346
+ const backend = backendFor(profile.backend);
347
+ const isManaged = backend.type === "managed-server";
348
+ const running = runningProfilesNow.some((r) => r.id === profile.id);
349
+ const fileMissing = isProfileFileMissing(profile);
350
+ const caps = profile.capabilities ?? {};
351
+ let status;
352
+ if (fileMissing) status = pc.red("File missing");
353
+ else if (running) status = pc.green("Running now");
354
+ else status = "Ready";
355
+ const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
356
+ // MTP: distinguish enabled vs available
357
+ let mtpLabel;
358
+ if (profile.drafterPath) {
359
+ mtpLabel = pc.green(`enabled (drafter: ${basename(profile.drafterPath)})`);
360
+ } else if (drafters ? matchDrafter(profile.modelPath, drafters) : null) {
361
+ mtpLabel = pc.yellow("available — reconfigure to enable");
362
+ } else if (caps.architecture === "gemma4") {
363
+ mtpLabel = pc.yellow("drafter not found");
364
+ }
365
+ const detailParts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps)];
366
+ if (mtpLabel) detailParts.push(mtpLabel);
367
+ const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
368
+ if (ctxLabel) detailParts.push(ctxLabel);
369
+ const rows = [
370
+ ["Status", status],
371
+ ["Details", detailParts.join(pc.dim(" · "))],
372
+ ["Runs with", backend.label],
373
+ ];
374
+ if (!isManaged && profile.modelPath) {
375
+ rows.push(["File", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath]);
376
+ }
377
+ console.log(renderCard(profile.label, renderRows(rows), { formatBorder: border }));
378
+ } else if (item.type === "new") {
379
+ const { model, drafter } = item;
380
+ const caps = detectCapabilities(model.path, model.mmprojPath);
381
+ const mtpAvailable = caps.mtp || Boolean(drafter);
382
+ const mtpLabel = mtpAvailable
383
+ ? pc.green("MTP ✓")
384
+ : (caps.architecture === "gemma4")
385
+ ? pc.yellow("MTP: needs drafter")
386
+ : null;
387
+ const detailParts = [humanCapabilitySummary(caps)];
388
+ if (mtpLabel) detailParts.push(mtpLabel);
389
+ detailParts.push(formatBytes(model.sizeBytes));
390
+ console.log(renderCard(model.label, renderRows([
391
+ ["Status", pc.yellow("Needs setup")],
392
+ ["Details", detailParts.join(pc.dim(" · "))],
393
+ ]), { formatBorder: pc.yellow }));
394
+ } else {
395
+ // managed
396
+ const { model, backendId } = item;
397
+ const backend = BACKENDS[backendId];
398
+ console.log(renderCard(model.label, renderRows([
399
+ ["Status", pc.dim(`Via ${backend.label}`)],
400
+ ["Details", [model.id, model.quant].filter(Boolean).join(pc.dim(" · "))],
401
+ ]), { formatBorder: pc.dim }));
402
+ }
403
+ }
404
+
342
405
  async function performAction(prompt, action, item) {
343
406
  if (action === "inspect") {
344
407
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));