offgrid-ai 0.4.7 → 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 +124 -185
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.4.7",
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
@@ -79,7 +79,7 @@ export async function mainFlow() {
79
79
 
80
80
  // 1. Check what backends are available
81
81
  const llamaBinary = await findLlamaServer();
82
- const { models: ggufModels } = await scanGgufModels();
82
+ const { models: ggufModels, drafters } = await scanGgufModels();
83
83
  const managedModels = await scanManagedModels();
84
84
  const profiles = await loadProfiles();
85
85
  const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
@@ -153,7 +153,7 @@ export async function mainFlow() {
153
153
 
154
154
  // 6. Interactive: one command center after onboarding.
155
155
  startInteractive("offgrid-ai");
156
- return await modelCommandCenter({ profiles, ggufModels, managedModels });
156
+ return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
157
157
  }
158
158
 
159
159
  // ── Model command center ────────────────────────────────────────────────────
@@ -172,27 +172,52 @@ 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 items = modelCatalogItems(normalized);
178
- await printModelCatalog(normalized, items);
179
- if (!process.stdin.isTTY) return;
180
-
181
- if (items.length === 0) return;
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;
183
+ }
182
184
 
183
185
  const prompt = createPrompt();
184
186
  try {
185
- const action = await prompt.choice("What would you like to do?", [
186
- { value: "run", label: "Start chatting", hint: "Start a local model and open Pi" },
187
- { value: "setup", label: "Set up", hint: "Configure settings, MTP, context window" },
188
- { value: "inspect", label: "See model details", hint: "Show advanced paths, ports, and flags" },
189
- { value: "benchmark", label: "Benchmark", hint: "Coming soon" },
190
- { value: "remove", label: "Remove a saved setup", hint: "Delete a model setup from offgrid-ai" },
191
- ], "run");
192
- if (action === "benchmark") return await benchmarkFlow();
193
- const item = await chooseCatalogItem(prompt, items, action);
194
- if (!item) return;
195
- return await handleCatalogAction(prompt, action, item);
187
+ while (true) {
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
+
206
+ const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
207
+ const selected = await prompt.choice("Select a model", options);
208
+ if (!selected) break;
209
+ const item = allItems.find((i) => itemKey(i) === selected);
210
+ if (!item) break;
211
+
212
+ 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
+ }
220
+ }
196
221
  } finally {
197
222
  prompt.close();
198
223
  }
@@ -232,167 +257,105 @@ function normalizeCatalog(catalog) {
232
257
  return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
233
258
  }
234
259
 
235
- async function printModelCatalog({ profiles, newModels, managedItems, drafters }, items = modelCatalogItems({ profiles, newModels, managedItems })) {
236
- const itemNumber = (predicate) => {
237
- const index = items.findIndex(predicate);
238
- return index === -1 ? " " : String(index + 1).padStart(2, " ");
239
- };
240
- const runningProfilesNow = [];
241
- for (const profile of profiles) {
242
- if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
243
- }
244
-
245
- const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
246
-
247
- const summaryBorder = fileMissingCount > 0 ? pc.red : newModels.length > 0 ? pc.yellow : pc.dim;
248
- console.log("\n" + renderCard("Your local AI workspace", renderRows([
249
- ["Setups", `${profiles.length} saved`],
250
- ["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} model${newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
251
- ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
252
- ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
253
- ["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? pc.yellow("Set up a downloaded model") : "Download a model"],
254
- ]), { formatBorder: summaryBorder }));
260
+ // ── Catalog item helpers ───────────────────────────────────────────────────
255
261
 
256
- console.log("\n" + pc.bold("Ready to chat"));
257
- if (profiles.length === 0) {
258
- console.log(renderCard("No saved setups yet", "Downloaded models will appear below. Set one up once, then it will be ready from here.", { formatBorder: pc.yellow }));
259
- } else {
260
- for (const profile of profiles) {
261
- const running = runningProfilesNow.some((item) => item.id === profile.id);
262
- const fileMissing = isProfileFileMissing(profile);
263
- const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
264
- console.log(profileCatalogCard(num, profile, { running, fileMissing, drafters }));
265
- }
266
- }
267
-
268
- console.log("\n" + pc.bold("Needs one-time setup"));
269
- if (newModels.length === 0) {
270
- console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.dim }));
271
- } else {
272
- for (const model of newModels.slice(0, 20)) {
273
- const caps = detectCapabilities(model.path, model.mmprojPath);
274
- const drafter = matchDrafter(model.path, drafters);
275
- const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
276
- console.log(downloadedModelCard(num, model, caps, { mtpAvailable: caps.mtp || Boolean(drafter), drafter }));
277
- }
278
- if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
279
- }
280
-
281
- for (const backendId of ["ollama", "omlx"]) {
282
- const backendItems = managedItems.filter((item) => item.backendId === backendId);
283
- if (backendItems.length === 0) continue;
284
- const be = BACKENDS[backendId];
285
- console.log("\n" + pc.bold(`Local models via ${be.label}`));
286
- for (const { model } of backendItems.slice(0, 10)) {
287
- const num = itemNumber((item) => item.type === "managed" && item.backendId === backendId && item.model.id === model.id);
288
- console.log(managedModelCard(num, model, be));
289
- }
290
- if (backendItems.length > 10) console.log(pc.dim(` ... and ${backendItems.length - 10} more`));
291
- }
292
- }
293
-
294
- function isProfileFileMissing(profile) {
295
- const backend = backendFor(profile.backend);
296
- if (backend.type === "managed-server") return false;
297
- if (!profile.modelPath) return true; // no path recorded means we can't verify
298
- return !existsSync(profile.modelPath);
262
+ function itemKey(item) {
263
+ if (item.type === "profile") return `profile:${item.profile.id}`;
264
+ if (item.type === "new") return `new:${item.model.path}`;
265
+ return `managed:${item.backendId}:${item.model.id}`;
299
266
  }
300
267
 
301
- function profileCatalogCard(num, profile, { running, fileMissing, drafters }) {
302
- const backend = backendFor(profile.backend);
303
- const caps = profile.capabilities ?? {};
304
- let status;
305
- if (fileMissing) {
306
- status = pc.red("File missing");
307
- } else if (running) {
308
- status = pc.green("Running now");
309
- } else {
310
- status = "Ready";
311
- }
312
- const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
313
- const mtpDrafter = profile.drafterPath
314
- ? pc.green("MTP")
315
- : (drafters ? matchDrafter(profile.modelPath, drafters) : null)
316
- ? pc.yellow("MTP available")
317
- : (caps.architecture === "gemma4")
318
- ? pc.yellow("MTP: needs drafter")
319
- : null;
320
- const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
321
- const capLabel = fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps);
322
- const detailParts = [capLabel];
323
- if (mtpDrafter) detailParts.push(mtpDrafter);
324
- if (ctxLabel) detailParts.push(ctxLabel);
325
- const detailLine = detailParts.join(pc.dim(" · "));
326
- return renderCard(`${num}. ${profile.label}`, renderRows([
327
- ["Status", status],
328
- ["Details", detailLine],
329
- ["Runs with", backend.label],
330
- ]), { formatBorder: border });
331
- }
332
-
333
- function downloadedModelCard(num, model, caps, { mtpAvailable } = {}) {
334
- const mtpLabel = mtpAvailable
335
- ? pc.green("MTP ✓")
336
- : (caps.architecture === "gemma4")
337
- ? pc.yellow("MTP: needs drafter")
338
- : null;
339
- const detailParts = [humanCapabilitySummary(caps)];
340
- if (mtpLabel) detailParts.push(mtpLabel);
341
- detailParts.push(formatBytes(model.sizeBytes));
342
- return renderCard(`${num}. ${model.label}`, renderRows([
343
- ["Status", pc.yellow("Needs setup")],
344
- ["Details", detailParts.join(pc.dim(" · "))],
345
- ]), { formatBorder: pc.yellow });
346
- }
347
-
348
- function managedModelCard(num, model, backend) {
349
- return renderCard(`${num}. ${model.label}`, renderRows([
350
- ["Status", pc.dim(`Via ${backend.label}`)],
351
- ["Details", [model.id, model.quant].filter(Boolean).join(pc.dim(" · "))],
352
- ]), { formatBorder: pc.dim });
353
- }
354
-
355
- function modelCatalogItems({ profiles, newModels, managedItems, drafters }) {
268
+ function buildCatalogItems(normalized) {
269
+ const { profiles, newModels, managedItems, drafters } = normalized;
356
270
  return [
357
- ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}`, fileMissing: isProfileFileMissing(profile) })),
271
+ ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
358
272
  ...newModels.map((model) => {
359
273
  const drafter = matchDrafter(model.path, drafters);
360
- return { type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}`, drafter };
274
+ return { type: "new", model, label: model.label, drafter };
361
275
  }),
362
- ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
276
+ ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
363
277
  ];
364
278
  }
365
279
 
366
- async function chooseCatalogItem(prompt, items, action) {
367
- if (action === "remove" && !items.some((item) => item.type === "profile")) {
368
- console.log(pc.yellow("No saved profiles to remove."));
369
- return null;
280
+ function modelSelectOption(item, { runningProfilesNow }) {
281
+ if (item.type === "profile") {
282
+ const { profile } = item;
283
+ const running = runningProfilesNow.some((r) => r.id === profile.id);
284
+ const fileMissing = item.fileMissing;
285
+ const caps = profile.capabilities ?? {};
286
+ 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 };
370
294
  }
371
-
372
- const input = await prompt.text(action === "remove" ? "Which saved setup should be removed? Enter its number" : "Which model? Enter its number", "");
373
- if (!input) return null;
374
- const index = Number(input) - 1;
375
- if (!Number.isInteger(index) || index < 0 || index >= items.length) {
376
- console.log(pc.yellow(`No item ${input}.`));
377
- return null;
295
+ if (item.type === "new") {
296
+ const { model, drafter } = item;
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 };
378
303
  }
304
+ // managed
305
+ const { model, backendId } = item;
306
+ const backend = BACKENDS[backendId];
307
+ return { value: itemKey(item), label: `${model.label} ${pc.dim(`via ${backend.label}`)}`, hint: model.id };
308
+ }
379
309
 
380
- const item = items[index];
381
- if (action === "remove" && item.type !== "profile") {
382
- console.log(pc.yellow("Only saved profiles can be removed."));
383
- return null;
310
+ function actionsForItem(item) {
311
+ if (item.type === "profile") {
312
+ const actions = [
313
+ { value: "run", label: "Start chatting", hint: "Launch and open Pi" },
314
+ { value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
315
+ { value: "inspect", label: "Details", hint: "Paths, ports, flags" },
316
+ ];
317
+ if (!item.fileMissing) {
318
+ actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
319
+ }
320
+ return actions;
384
321
  }
385
- return item;
322
+ if (item.type === "new") {
323
+ return [
324
+ { value: "setup", label: "Set up", hint: "Configure and save" },
325
+ { value: "inspect", label: "Details", hint: "Model info" },
326
+ ];
327
+ }
328
+ // managed
329
+ return [
330
+ { value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
331
+ { value: "inspect", label: "Details", hint: "Model info" },
332
+ ];
386
333
  }
387
334
 
388
- async function handleCatalogAction(prompt, action, item) {
335
+ function isProfileFileMissing(profile) {
336
+ const backend = backendFor(profile.backend);
337
+ if (backend.type === "managed-server") return false;
338
+ if (!profile.modelPath) return true;
339
+ return !existsSync(profile.modelPath);
340
+ }
341
+
342
+ async function performAction(prompt, action, item) {
389
343
  if (action === "inspect") {
390
344
  if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
391
345
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
392
346
  return printGgufModelDetails(item.model, item.drafter);
393
347
  }
394
-
395
- if (action === "setup") {
348
+ if (action === "run") {
349
+ if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
350
+ // Shouldn't reach here for new/managed, but handle gracefully
351
+ const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
352
+ const configured = await configureLocalProfile(prompt, profile);
353
+ if (!configured) return;
354
+ await saveProfile(configured);
355
+ await syncPiConfig(configured);
356
+ return await runProfile(configured);
357
+ }
358
+ if (action === "reconfigure" || action === "setup") {
396
359
  if (item.type === "profile") {
397
360
  const profile = await readProfile(item.profile.id);
398
361
  const configured = await configureLocalProfile(prompt, profile);
@@ -411,26 +374,10 @@ async function handleCatalogAction(prompt, action, item) {
411
374
  await saveProfile(configured);
412
375
  return await syncPiConfig(configured);
413
376
  }
414
-
415
- if (action === "run") {
416
- if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
417
- if (item.type === "managed") {
418
- const profile = createManagedProfile(item.model, item.backendId);
419
- await saveProfile(profile);
420
- await syncPiConfig(profile);
421
- return await runProfile(profile);
422
- }
423
- const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
424
- const configured = await configureLocalProfile(prompt, profile);
425
- if (!configured) return;
426
- await saveProfile(configured);
427
- await syncPiConfig(configured);
428
- return await runProfile(configured);
429
- }
430
-
431
377
  if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
432
378
  }
433
379
 
380
+
434
381
  async function printProfileDetails(profile) {
435
382
  const backend = backendFor(profile.backend);
436
383
  const isManaged = backend.type === "managed-server";
@@ -694,14 +641,6 @@ async function removeProfileInteractive(id) {
694
641
 
695
642
  // ── Benchmark (stub) ────────────────────────────────────────────────────────
696
643
 
697
- async function benchmarkFlow() {
698
- console.log("\n" + renderCard("Benchmark", renderRows([
699
- ["Status", pc.yellow("Coming soon")],
700
- ["What it will do", "Compare local models with repeatable prompts"],
701
- ["For now", "Start a model with offgrid-ai, then run benchmarks manually"],
702
- ]), { formatBorder: pc.yellow }));
703
- }
704
-
705
644
  // ── Status ──────────────────────────────────────────────────────────────────
706
645
 
707
646
  async function statusCommand() {