offgrid-ai 0.5.0 → 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 +110 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.5.0",
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
@@ -172,54 +172,49 @@ 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
+ for (const item of allItems) console.log(item.label);
181
+ return;
181
182
  }
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
183
 
197
184
  const prompt = createPrompt();
198
185
  try {
199
186
  while (true) {
200
- // Build model select options
187
+ const catalog = await loadModelCatalog();
188
+ const normalized = normalizeCatalog(catalog);
189
+ const allItems = buildCatalogItems(normalized);
190
+ if (allItems.length === 0) { console.log(pc.dim("No models found.")); break; }
191
+ const runningProfilesNow = [];
192
+ for (const profile of normalized.profiles) {
193
+ if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
194
+ }
195
+ const fileMissingCount = normalized.profiles.filter((p) => isProfileFileMissing(p)).length;
196
+ const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
197
+ console.log("\n" + renderCard("Your local AI workspace", renderRows([
198
+ ["Setups", `${normalized.profiles.length} saved`],
199
+ ["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
200
+ ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
201
+ ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
202
+ ]), { formatBorder: summaryBorder }));
203
+
201
204
  const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
202
205
  const selected = await prompt.choice("Select a model", options);
203
206
  if (!selected) break;
204
207
  const item = allItems.find((i) => itemKey(i) === selected);
205
208
  if (!item) break;
206
209
 
207
- // Build contextual actions
210
+ // Show detail card before action menu
211
+ console.log("");
212
+ printItemCard(item, { runningProfilesNow, drafters: normalized.drafters });
213
+
208
214
  const actions = actionsForItem(item);
209
- if (actions.length === 1) {
210
- // Only one action — just do it
211
- await performAction(prompt, actions[0].value, item);
212
- } else {
213
- const action = await prompt.choice(item.label, actions, actions[0].value);
214
- if (!action) continue;
215
- if (action === "back") continue;
216
- await performAction(prompt, action, item);
217
- }
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
- }
215
+ const action = await prompt.choice(item.label, actions, actions[0].value);
216
+ if (!action) continue;
217
+ await performAction(prompt, action, item);
223
218
  }
224
219
  } finally {
225
220
  prompt.close();
@@ -285,31 +280,32 @@ function modelSelectOption(item, { runningProfilesNow }) {
285
280
  const { profile } = item;
286
281
  const running = runningProfilesNow.some((r) => r.id === profile.id);
287
282
  const fileMissing = item.fileMissing;
288
- const caps = profile.capabilities ?? {};
289
283
  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(" · ")) };
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 };
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 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 };
308
304
  }
309
305
  // managed
310
306
  const { model, backendId } = item;
311
307
  const backend = BACKENDS[backendId];
312
- return { value: itemKey(item), label: model.label, hint: `${backend.label} · ${model.id}` };
308
+ return { value: itemKey(item), label: `${model.label} ${pc.dim(`via ${backend.label}`)}`, hint: model.id };
313
309
  }
314
310
 
315
311
  function actionsForItem(item) {
@@ -344,6 +340,68 @@ function isProfileFileMissing(profile) {
344
340
  return !existsSync(profile.modelPath);
345
341
  }
346
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
+
347
405
  async function performAction(prompt, action, item) {
348
406
  if (action === "inspect") {
349
407
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));