offgrid-ai 0.3.14 → 0.3.15

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 +229 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
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
@@ -52,6 +52,8 @@ export async function run(argv) {
52
52
 
53
53
  if (command === "help" || command === "--help" || command === "-h") return printHelp();
54
54
  if (command === "version" || command === "--version" || command === "-v") return printVersion();
55
+ if (command === "models") return modelsCommand(argv.slice(1));
56
+ if (command === "run") return runCommand(argv.slice(1));
55
57
  if (command === "status") return statusCommand();
56
58
  if (command === "stop") return stopCommand(argv.slice(1));
57
59
  if (command === "uninstall" || command === "--uninstall") return uninstallCommand(argv.slice(1));
@@ -216,6 +218,230 @@ export async function mainFlow() {
216
218
  }
217
219
  }
218
220
 
221
+ // ── Explicit model/run commands ─────────────────────────────────────────────
222
+
223
+ async function modelsCommand(argv) {
224
+ await ensureDirs();
225
+ if (process.stdin.isTTY) startInteractive("offgrid-ai models");
226
+ const catalog = await loadModelCatalog();
227
+
228
+ if (argv[0]) {
229
+ const profile = await readProfile(argv[0]);
230
+ await printProfileDetails(profile);
231
+ return;
232
+ }
233
+
234
+ await printModelCatalog(catalog);
235
+ if (!process.stdin.isTTY) return;
236
+
237
+ const items = modelCatalogItems(catalog);
238
+ if (items.length === 0) return;
239
+
240
+ const prompt = createPrompt();
241
+ try {
242
+ const action = await prompt.choice("Action", [
243
+ { value: "inspect", label: "Inspect", hint: "View profile/model details" },
244
+ { value: "setup", label: "Set up / sync", hint: "Create profile or sync Pi" },
245
+ { value: "run", label: "Run", hint: "Start server and launch Pi" },
246
+ { value: "remove", label: "Remove", hint: "Delete a saved profile" },
247
+ ], "inspect");
248
+ const item = await chooseCatalogItem(prompt, items, action);
249
+ if (!item) return;
250
+ return await handleCatalogAction(prompt, action, item);
251
+ } finally {
252
+ prompt.close();
253
+ }
254
+ }
255
+
256
+ async function runCommand(argv) {
257
+ await ensureDirs();
258
+ const { positional } = parseOptions(argv);
259
+ if (positional[0]) {
260
+ const profile = await readProfile(positional[0]);
261
+ return await runProfile(profile);
262
+ }
263
+
264
+ const catalog = await loadModelCatalog();
265
+ if (!process.stdin.isTTY) throw new Error("Run requires a profile id in non-interactive mode: offgrid-ai run <profile>");
266
+ startInteractive("offgrid-ai run");
267
+ await printModelCatalog(catalog);
268
+ const prompt = createPrompt();
269
+ try {
270
+ return await pickAndRun(prompt, catalog.profiles, catalog.newModels, catalog.managedItems);
271
+ } finally {
272
+ prompt.close();
273
+ }
274
+ }
275
+
276
+ async function loadModelCatalog() {
277
+ const [profiles, ggufModels, managedModels] = await Promise.all([
278
+ loadProfiles(),
279
+ scanGgufModels(),
280
+ scanManagedModels(),
281
+ ]);
282
+ const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
283
+ const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
284
+ const managedItems = [];
285
+ for (const { backendId, models } of managedModels) {
286
+ const profiledAliases = new Set(
287
+ profiles.filter((p) => p.backend === backendId).map((p) => backendId === "ollama" ? `ollama:${p.ollamaModel ?? p.modelAlias}` : `omlx:${p.omlxModel ?? p.modelAlias}`)
288
+ );
289
+ for (const model of models) {
290
+ if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
291
+ }
292
+ }
293
+ return { profiles, ggufModels, managedModels, newModels, managedItems };
294
+ }
295
+
296
+ async function printModelCatalog({ profiles, newModels, managedModels }) {
297
+ if (profiles.length > 0) {
298
+ console.log(pc.bold("\nSaved profiles"));
299
+ for (const profile of profiles) {
300
+ const backend = backendFor(profile.backend);
301
+ const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
302
+ const running = await isProfileRunning(profile);
303
+ const piConfigured = await hasPiModel(profile);
304
+ const c = colorMap[profile.backend] ?? pc.magenta;
305
+ console.log(` ${running ? pc.green("●") : pc.dim("○")} ${pc.bold(profile.label)} ${c(`[${backend.label}]`)} · ${pc.cyan(profile.modelAlias)} ${piConfigured ? pc.green("· Pi synced") : pc.yellow("· Pi not synced")}`);
306
+ }
307
+ } else {
308
+ console.log(pc.bold("\nSaved profiles"));
309
+ console.log(pc.dim(" None yet."));
310
+ }
311
+
312
+ if (newModels.length > 0) {
313
+ console.log(pc.bold("\nNew GGUF models"));
314
+ for (const model of newModels.slice(0, 20)) {
315
+ console.log(` ${pc.cyan(model.label)} ${pc.dim(model.quant ?? "")} · ${pc.dim(formatBytes(model.sizeBytes))}`);
316
+ }
317
+ if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
318
+ }
319
+
320
+ for (const { backendId, models } of managedModels) {
321
+ if (models.length === 0) continue;
322
+ const be = BACKENDS[backendId];
323
+ console.log(pc.bold(`\n${be.label} models`));
324
+ for (const model of models.slice(0, 10)) console.log(` ${pc.cyan(model.label)}`);
325
+ if (models.length > 10) console.log(pc.dim(` ... and ${models.length - 10} more`));
326
+ }
327
+ }
328
+
329
+ function modelCatalogItems({ profiles, newModels, managedItems }) {
330
+ return [
331
+ ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}` })),
332
+ ...newModels.map((model) => ({ type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}` })),
333
+ ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
334
+ ];
335
+ }
336
+
337
+ async function chooseCatalogItem(prompt, items, action) {
338
+ const allowed = action === "remove" ? items.filter((item) => item.type === "profile") : items;
339
+ if (allowed.length === 0) {
340
+ console.log(pc.yellow(action === "remove" ? "No saved profiles to remove." : "No models available."));
341
+ return null;
342
+ }
343
+ const selected = await prompt.choice("Select", allowed.map((item, index) => ({
344
+ value: String(index),
345
+ label: item.label,
346
+ hint: item.hint,
347
+ })), "0");
348
+ return allowed[Number(selected)];
349
+ }
350
+
351
+ async function handleCatalogAction(prompt, action, item) {
352
+ if (action === "inspect") {
353
+ if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
354
+ if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
355
+ return printGgufModelDetails(item.model);
356
+ }
357
+
358
+ if (action === "setup") {
359
+ if (item.type === "profile") return await syncPiConfig(await readProfile(item.profile.id));
360
+ if (item.type === "managed") {
361
+ const profile = createManagedProfile(item.model, item.backendId);
362
+ await saveProfile(profile);
363
+ return await syncPiConfig(profile);
364
+ }
365
+ const profile = await createProfileFromModel(item.model);
366
+ const configured = await configureLocalProfile(prompt, profile);
367
+ if (!configured) return;
368
+ await saveProfile(configured);
369
+ return await syncPiConfig(configured);
370
+ }
371
+
372
+ if (action === "run") {
373
+ if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
374
+ if (item.type === "managed") {
375
+ const profile = createManagedProfile(item.model, item.backendId);
376
+ await saveProfile(profile);
377
+ await syncPiConfig(profile);
378
+ return await runProfile(profile);
379
+ }
380
+ const profile = await createProfileFromModel(item.model);
381
+ const configured = await configureLocalProfile(prompt, profile);
382
+ if (!configured) return;
383
+ await saveProfile(configured);
384
+ await syncPiConfig(configured);
385
+ return await runProfile(configured);
386
+ }
387
+
388
+ if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
389
+ }
390
+
391
+ async function printProfileDetails(profile) {
392
+ const backend = backendFor(profile.backend);
393
+ const isManaged = backend.type === "managed-server";
394
+ const piConfigured = await hasPiModel(profile);
395
+ console.log("\n" + renderSection("Profile", renderRows([
396
+ ["ID", pc.cyan(profile.id)],
397
+ ["Label", pc.bold(profile.label)],
398
+ ["Backend", backend.label],
399
+ ["Endpoint", pc.green(profile.baseUrl)],
400
+ ...(!isManaged ? [
401
+ ["Model", profile.modelPath ?? "unknown"],
402
+ ["MMProj", profile.mmprojPath ?? "none"],
403
+ ["Memory", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
404
+ ] : []),
405
+ ["Alias", pc.cyan(profile.modelAlias)],
406
+ ["Pi", piConfigured ? pc.green("configured") : pc.yellow("not synced")],
407
+ ])));
408
+
409
+ if (!isManaged && profile.commandArgv) {
410
+ console.log("\n" + pc.bold("llama-server command"));
411
+ console.log(pc.dim(buildPrettyCommand(profile)));
412
+ }
413
+ }
414
+
415
+ function printGgufModelDetails(model) {
416
+ console.log("\n" + renderSection("GGUF model", renderRows([
417
+ ["Label", pc.bold(model.label)],
418
+ ["Model", model.path],
419
+ ["MMProj", model.mmprojPath ?? "none"],
420
+ ["Quant", model.quant ?? "unknown"],
421
+ ["Size", formatBytes(model.sizeBytes)],
422
+ ])));
423
+ }
424
+
425
+ function printManagedModelDetails(model, backend) {
426
+ console.log("\n" + renderSection(`${backend.label} model`, renderRows([
427
+ ["Label", pc.bold(model.label)],
428
+ ["ID", pc.cyan(model.id)],
429
+ ["Quant", model.quant ?? "unknown"],
430
+ ["Family", model.family ?? "unknown"],
431
+ ])));
432
+ }
433
+
434
+ function createManagedProfile(model, backendId) {
435
+ return normalizeProfile({
436
+ id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
437
+ label: model.label,
438
+ backend: backendId,
439
+ modelAlias: model.aliasSuggestion,
440
+ ...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
441
+ ...(backendId === "omlx" ? { omlxModel: model.id } : {}),
442
+ });
443
+ }
444
+
219
445
  // ── Pick and run ────────────────────────────────────────────────────────────
220
446
 
221
447
  async function pickAndRun(prompt, profiles, newModels, managedItems) {
@@ -981,7 +1207,9 @@ function printHelp() {
981
1207
  console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner
982
1208
 
983
1209
  Usage:
984
- offgrid-ai Pick a model and run it
1210
+ offgrid-ai Friendly shortcut: pick a model and run it
1211
+ offgrid-ai models List, inspect, set up, sync, or remove models
1212
+ offgrid-ai run Pick and run a model (or: offgrid-ai run <profile>)
985
1213
  offgrid-ai status Show running local models
986
1214
  offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
987
1215
  offgrid-ai uninstall Remove offgrid-ai, clean up PATH, optionally keep profiles