offgrid-ai 0.3.13 → 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 +281 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.3.13",
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));
@@ -202,26 +204,244 @@ export async function mainFlow() {
202
204
  }
203
205
 
204
206
  // Pick what to do
205
- while (true) {
206
- const action = await prompt.choice("What next?", [
207
- { value: "run", label: "Run a model", hint: "Start server and launch Pi" },
208
- ...(profiles.length > 0 ? [{ value: "manage", label: "Manage profiles", hint: "Sync, remove, or inspect" }] : []),
209
- { value: "benchmark", label: "Benchmark", hint: "Run a benchmark prompt" },
210
- ], "run");
211
-
212
- if (action === "run") return await pickAndRun(prompt, profiles, newModels, managedItems);
213
- if (action === "manage") {
214
- const result = await manageProfiles(prompt, profiles);
215
- if (result === "back") continue;
216
- return result;
217
- }
218
- if (action === "benchmark") return await benchmarkFlow(prompt, profiles);
219
- }
207
+ const action = await prompt.choice("What next?", [
208
+ { value: "run", label: "Run a model", hint: "Start server and launch Pi" },
209
+ ...(profiles.length > 0 ? [{ value: "manage", label: "Manage profiles", hint: "Sync, remove, or inspect" }] : []),
210
+ { value: "benchmark", label: "Benchmark", hint: "Run a benchmark prompt" },
211
+ ], "run");
212
+
213
+ if (action === "run") return await pickAndRun(prompt, profiles, newModels, managedItems);
214
+ if (action === "manage") return await manageProfiles(prompt, profiles);
215
+ if (action === "benchmark") return await benchmarkFlow(prompt, profiles);
216
+ } finally {
217
+ prompt.close();
218
+ }
219
+ }
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);
220
271
  } finally {
221
272
  prompt.close();
222
273
  }
223
274
  }
224
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
+
225
445
  // ── Pick and run ────────────────────────────────────────────────────────────
226
446
 
227
447
  async function pickAndRun(prompt, profiles, newModels, managedItems) {
@@ -391,64 +611,51 @@ async function runProfile(profile, options = {}) {
391
611
  // ── Manage profiles ─────────────────────────────────────────────────────────
392
612
 
393
613
  async function manageProfiles(prompt, profiles) {
394
- while (true) {
395
- const choices = profiles.map((p) => ({
396
- value: p.id,
397
- label: p.label,
398
- hint: `${p.modelAlias} · ${p.baseUrl}`,
399
- }));
400
- choices.push({ value: "__back", label: " Back" });
401
-
402
- const selected = await prompt.choice("Which profile?", choices, choices[0].value);
403
- if (selected === "__back") return "back";
404
-
405
- const profile = await readProfile(selected);
406
- const backend = backendFor(profile.backend);
407
- const isManaged = backend.type === "managed-server";
408
- const piConfigured = await hasPiModel(profile);
409
-
410
- // Show profile details
614
+ const choices = profiles.map((p) => ({
615
+ value: p.id,
616
+ label: p.label,
617
+ hint: `${p.modelAlias} · ${p.baseUrl}`,
618
+ }));
619
+
620
+ const selected = await prompt.choice("Which profile?", choices, choices[0].value);
621
+ const profile = await readProfile(selected);
622
+ const backend = backendFor(profile.backend);
623
+ const isManaged = backend.type === "managed-server";
624
+ const piConfigured = await hasPiModel(profile);
625
+
626
+ // Show profile details
627
+ console.log("");
628
+ console.log(renderSection("Profile", renderRows([
629
+ ["ID", pc.cyan(profile.id)],
630
+ ["Label", pc.bold(profile.label)],
631
+ ["Backend", backend.label],
632
+ ["Endpoint", pc.green(profile.baseUrl)],
633
+ ...(!isManaged ? [
634
+ ["Model", profile.modelPath ?? "unknown"],
635
+ ["MMProj", profile.mmprojPath ?? "none"],
636
+ ["Memory", existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
637
+ ] : []),
638
+ ["Alias", pc.cyan(profile.modelAlias)],
639
+ ["Pi", piConfigured ? pc.green("configured") : pc.yellow("not synced")],
640
+ ])));
641
+
642
+ if (!isManaged && profile.commandArgv) {
411
643
  console.log("");
412
- console.log(renderSection("Profile", renderRows([
413
- ["ID", pc.cyan(profile.id)],
414
- ["Label", pc.bold(profile.label)],
415
- ["Backend", backend.label],
416
- ["Endpoint", pc.green(profile.baseUrl)],
417
- ...(!isManaged ? [
418
- ["Model", profile.modelPath ?? "unknown"],
419
- ["MMProj", profile.mmprojPath ?? "none"],
420
- ["Memory", existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
421
- ] : []),
422
- ["Alias", pc.cyan(profile.modelAlias)],
423
- ["Pi", piConfigured ? pc.green("configured") : pc.yellow("not synced")],
424
- ])));
425
-
426
- if (!isManaged && profile.commandArgv) {
427
- console.log("");
428
- console.log(pc.bold("llama-server command"));
429
- console.log(pc.dim(buildPrettyCommand(profile)));
430
- }
431
-
432
- const action = await prompt.choice("Action", [
433
- { value: "sync", label: piConfigured ? `${pc.green("✓")} Pi config synced` : "Sync Pi config", hint: piConfigured ? "Already in ~/.pi/agent/models.json" : "Update ~/.pi/agent/models.json" },
434
- { value: "run", label: "Run", hint: "Start server + Pi" },
435
- ...(isManaged ? [] : [{ value: "server", label: "Server only", hint: "Start server, no harness" }]),
436
- { value: "remove", label: "Remove", hint: "Delete profile + Pi config" },
437
- { value: "__back", label: "← Back", hint: "Choose another profile" },
438
- ], "sync");
439
-
440
- if (action === "__back") continue;
441
- if (action === "sync") {
442
- await syncPiConfig(profile);
443
- continue;
444
- }
445
- if (action === "run") return await runProfile(profile);
446
- if (action === "server") return await runProfile(profile, { with: "server" });
447
- if (action === "remove") {
448
- await removeProfileInteractive(profile.id);
449
- return;
450
- }
644
+ console.log(pc.bold("llama-server command"));
645
+ console.log(pc.dim(buildPrettyCommand(profile)));
451
646
  }
647
+
648
+ const action = await prompt.choice("Action", [
649
+ { value: "sync", label: piConfigured ? `${pc.green("✓")} Pi config synced` : "Sync Pi config", hint: piConfigured ? "Already in ~/.pi/agent/models.json" : "Update ~/.pi/agent/models.json" },
650
+ { value: "run", label: "Run", hint: "Start server + Pi" },
651
+ ...(isManaged ? [] : [{ value: "server", label: "Server only", hint: "Start server, no harness" }]),
652
+ { value: "remove", label: "Remove", hint: "Delete profile + Pi config" },
653
+ ], "sync");
654
+
655
+ if (action === "sync") return await syncPiConfig(profile);
656
+ if (action === "run") return await runProfile(profile);
657
+ if (action === "server") return await runProfile(profile, { with: "server" });
658
+ if (action === "remove") return await removeProfileInteractive(profile.id);
452
659
  }
453
660
 
454
661
  async function removeProfileInteractive(id) {
@@ -1000,7 +1207,9 @@ function printHelp() {
1000
1207
  console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner
1001
1208
 
1002
1209
  Usage:
1003
- 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>)
1004
1213
  offgrid-ai status Show running local models
1005
1214
  offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
1006
1215
  offgrid-ai uninstall Remove offgrid-ai, clean up PATH, optionally keep profiles