offgrid-ai 0.3.14 → 0.3.16

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 +204 -172
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
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));
@@ -151,156 +153,236 @@ export async function mainFlow() {
151
153
  return;
152
154
  }
153
155
 
154
- // 6. Interactive: pick an action
156
+ // 6. Interactive: one command center after onboarding.
155
157
  startInteractive("offgrid-ai");
156
- const prompt = createPrompt();
157
- try {
158
- // Show what we found
159
- const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
160
- const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
161
-
162
- // Managed backend models
163
- const managedItems = [];
164
- for (const { backendId, models } of managedModels) {
165
- const profiledAliases = new Set(
166
- profiles.filter((p) => p.backend === backendId).map((p) => backendId === "ollama" ? `ollama:${p.ollamaModel ?? p.modelAlias}` : `omlx:${p.omlxModel ?? p.modelAlias}`)
167
- );
168
- for (const model of models) {
169
- if (!profiledAliases.has(`${backendId}:${model.id}`)) {
170
- managedItems.push({ model, backendId });
171
- }
172
- }
173
- }
158
+ return await modelCommandCenter({ profiles, ggufModels, managedModels });
159
+ }
174
160
 
175
- // Show what we found
176
- if (profiles.length > 0) {
177
- console.log(pc.bold("\nSaved profiles"));
178
- for (const profile of profiles) {
179
- const backend = backendFor(profile.backend);
180
- const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
181
- const running = await isProfileRunning(profile);
182
- const c = colorMap[profile.backend] ?? pc.magenta;
183
- console.log(` ${running ? pc.green("●") : pc.dim("○")} ${pc.bold(profile.label)} ${c(`[${backend.label}]`)} · ${pc.cyan(profile.modelAlias)}`);
184
- }
185
- }
186
- if (newModels.length > 0) {
187
- console.log(pc.bold("\nNew models"));
188
- for (const model of newModels.slice(0, 10)) {
189
- console.log(` ${pc.cyan(model.label)} ${pc.dim(model.quant ?? "")} · ${pc.dim(formatBytes(model.sizeBytes))}`);
190
- }
191
- if (newModels.length > 10) console.log(pc.dim(` ... and ${newModels.length - 10} more`));
192
- }
193
- for (const { backendId, models } of managedModels) {
194
- if (models.length > 0) {
195
- const be = BACKENDS[backendId];
196
- console.log(pc.bold(`\n${be.label} models`));
197
- for (const model of models.slice(0, 5)) {
198
- console.log(` ${pc.cyan(model.label)}`);
199
- }
200
- if (models.length > 5) console.log(pc.dim(` ... and ${models.length - 5} more`));
201
- }
202
- }
161
+ // ── Model command center ────────────────────────────────────────────────────
203
162
 
204
- // Pick what to do
205
- const action = await prompt.choice("What next?", [
206
- { value: "run", label: "Run a model", hint: "Start server and launch Pi" },
207
- ...(profiles.length > 0 ? [{ value: "manage", label: "Manage profiles", hint: "Sync, remove, or inspect" }] : []),
208
- { value: "benchmark", label: "Benchmark", hint: "Run a benchmark prompt" },
209
- ], "run");
163
+ async function modelsCommand(argv) {
164
+ await ensureDirs();
165
+ const catalog = await loadModelCatalog();
166
+
167
+ if (argv[0]) {
168
+ const profile = await readProfile(argv[0]);
169
+ await printProfileDetails(profile);
170
+ return;
171
+ }
172
+
173
+ if (process.stdin.isTTY) startInteractive("offgrid-ai");
174
+ return await modelCommandCenter(catalog);
175
+ }
210
176
 
211
- if (action === "run") return await pickAndRun(prompt, profiles, newModels, managedItems);
212
- if (action === "manage") return await manageProfiles(prompt, profiles);
213
- if (action === "benchmark") return await benchmarkFlow(prompt, profiles);
177
+ async function modelCommandCenter(catalog) {
178
+ const normalized = normalizeCatalog(catalog);
179
+ await printModelCatalog(normalized);
180
+ if (!process.stdin.isTTY) return;
181
+
182
+ const items = modelCatalogItems(normalized);
183
+ if (items.length === 0) return;
184
+
185
+ const prompt = createPrompt();
186
+ try {
187
+ const action = await prompt.choice("What do you want to do?", [
188
+ { value: "inspect", label: "Inspect", hint: "View details" },
189
+ { value: "setup", label: "Set up / sync", hint: "Create profile or sync Pi" },
190
+ { value: "run", label: "Run", hint: "Start server and launch Pi" },
191
+ { value: "benchmark", label: "Benchmark", hint: "Coming soon: local benchmark project" },
192
+ { value: "remove", label: "Remove", hint: "Delete a saved profile" },
193
+ ], "run");
194
+ if (action === "benchmark") return await benchmarkFlow();
195
+ const item = await chooseCatalogItem(prompt, items, action);
196
+ if (!item) return;
197
+ return await handleCatalogAction(prompt, action, item);
214
198
  } finally {
215
199
  prompt.close();
216
200
  }
217
201
  }
218
202
 
219
- // ── Pick and run ────────────────────────────────────────────────────────────
203
+ async function runCommand(argv) {
204
+ await ensureDirs();
205
+ const { positional } = parseOptions(argv);
206
+ if (!positional[0]) return await mainFlow();
207
+ const profile = await readProfile(positional[0]);
208
+ return await runProfile(profile);
209
+ }
220
210
 
221
- async function pickAndRun(prompt, profiles, newModels, managedItems) {
222
- // If there's exactly one profile and it's already running, offer to connect or start fresh
223
- const choices = [];
211
+ async function loadModelCatalog() {
212
+ const [profiles, ggufModels, managedModels] = await Promise.all([
213
+ loadProfiles(),
214
+ scanGgufModels(),
215
+ scanManagedModels(),
216
+ ]);
217
+ return normalizeCatalog({ profiles, ggufModels, managedModels });
218
+ }
224
219
 
225
- // Existing profiles
226
- for (const profile of profiles) {
227
- const running = await isProfileRunning(profile);
228
- const backend = backendFor(profile.backend);
229
- const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
230
- const c = colorMap[profile.backend] ?? pc.magenta;
231
- choices.push({
232
- value: `profile:${profile.id}`,
233
- label: `${running ? pc.green("● ") : ""}${profile.label}`,
234
- hint: `${c(backend.label)} · ${profile.modelAlias} · ${profile.baseUrl}`,
235
- });
236
- }
237
-
238
- // New GGUF models
239
- for (const model of newModels.slice(0, 20)) {
240
- choices.push({
241
- value: `new:${model.path}`,
242
- label: model.label,
243
- hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}`,
244
- });
245
- }
246
-
247
- // Managed models
248
- for (const { model, backendId } of managedItems) {
220
+ function normalizeCatalog(catalog) {
221
+ if (catalog.newModels && catalog.managedItems) return catalog;
222
+ const { profiles, ggufModels, managedModels } = catalog;
223
+ const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
224
+ const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
225
+ const managedItems = [];
226
+ for (const { backendId, models } of managedModels) {
227
+ const profiledAliases = new Set(
228
+ profiles.filter((p) => p.backend === backendId).map((p) => backendId === "ollama" ? `ollama:${p.ollamaModel ?? p.modelAlias}` : `omlx:${p.omlxModel ?? p.modelAlias}`)
229
+ );
230
+ for (const model of models) {
231
+ if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
232
+ }
233
+ }
234
+ return { profiles, ggufModels, managedModels, newModels, managedItems };
235
+ }
236
+
237
+ async function printModelCatalog({ profiles, newModels, managedModels }) {
238
+ if (profiles.length > 0) {
239
+ console.log(pc.bold("\nSaved profiles"));
240
+ for (const profile of profiles) {
241
+ const backend = backendFor(profile.backend);
242
+ const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
243
+ const running = await isProfileRunning(profile);
244
+ const piConfigured = await hasPiModel(profile);
245
+ const c = colorMap[profile.backend] ?? pc.magenta;
246
+ 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")}`);
247
+ }
248
+ } else {
249
+ console.log(pc.bold("\nSaved profiles"));
250
+ console.log(pc.dim(" None yet."));
251
+ }
252
+
253
+ if (newModels.length > 0) {
254
+ console.log(pc.bold("\nNew GGUF models"));
255
+ for (const model of newModels.slice(0, 20)) {
256
+ console.log(` ${pc.cyan(model.label)} ${pc.dim(model.quant ?? "")} · ${pc.dim(formatBytes(model.sizeBytes))}`);
257
+ }
258
+ if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
259
+ }
260
+
261
+ for (const { backendId, models } of managedModels) {
262
+ if (models.length === 0) continue;
249
263
  const be = BACKENDS[backendId];
250
- choices.push({
251
- value: `managed:${backendId}:${model.id}`,
252
- label: model.label,
253
- hint: `${be.label}`,
254
- });
264
+ console.log(pc.bold(`\n${be.label} models`));
265
+ for (const model of models.slice(0, 10)) console.log(` ${pc.cyan(model.label)}`);
266
+ if (models.length > 10) console.log(pc.dim(` ... and ${models.length - 10} more`));
255
267
  }
268
+ }
256
269
 
257
- if (choices.length === 0) {
258
- console.log(pc.yellow("No models available."));
259
- return;
270
+ function modelCatalogItems({ profiles, newModels, managedItems }) {
271
+ return [
272
+ ...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, hint: `${profile.modelAlias} · ${profile.baseUrl}` })),
273
+ ...newModels.map((model) => ({ type: "new", model, label: model.label, hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}` })),
274
+ ...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label, hint: BACKENDS[backendId].label })),
275
+ ];
276
+ }
277
+
278
+ async function chooseCatalogItem(prompt, items, action) {
279
+ const allowed = action === "remove" ? items.filter((item) => item.type === "profile") : items;
280
+ if (allowed.length === 0) {
281
+ console.log(pc.yellow(action === "remove" ? "No saved profiles to remove." : "No models available."));
282
+ return null;
260
283
  }
284
+ const selected = await prompt.choice("Select", allowed.map((item, index) => ({
285
+ value: String(index),
286
+ label: item.label,
287
+ hint: item.hint,
288
+ })), "0");
289
+ return allowed[Number(selected)];
290
+ }
261
291
 
262
- const selected = await prompt.choice("Pick a model", choices, choices[0].value);
292
+ async function handleCatalogAction(prompt, action, item) {
293
+ if (action === "inspect") {
294
+ if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
295
+ if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
296
+ return printGgufModelDetails(item.model);
297
+ }
263
298
 
264
- if (selected.startsWith("profile:")) {
265
- const id = selected.slice("profile:".length);
266
- const profile = await readProfile(id);
267
- return await runProfile(profile);
299
+ if (action === "setup") {
300
+ if (item.type === "profile") return await syncPiConfig(await readProfile(item.profile.id));
301
+ if (item.type === "managed") {
302
+ const profile = createManagedProfile(item.model, item.backendId);
303
+ await saveProfile(profile);
304
+ return await syncPiConfig(profile);
305
+ }
306
+ const profile = await createProfileFromModel(item.model);
307
+ const configured = await configureLocalProfile(prompt, profile);
308
+ if (!configured) return;
309
+ await saveProfile(configured);
310
+ return await syncPiConfig(configured);
268
311
  }
269
312
 
270
- if (selected.startsWith("new:")) {
271
- const modelPath = selected.slice("new:".length);
272
- const model = newModels.find((m) => m.path === modelPath);
273
- if (!model) throw new Error("Model not found.");
274
- const profile = await createProfileFromModel(model);
313
+ if (action === "run") {
314
+ if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
315
+ if (item.type === "managed") {
316
+ const profile = createManagedProfile(item.model, item.backendId);
317
+ await saveProfile(profile);
318
+ await syncPiConfig(profile);
319
+ return await runProfile(profile);
320
+ }
321
+ const profile = await createProfileFromModel(item.model);
275
322
  const configured = await configureLocalProfile(prompt, profile);
276
323
  if (!configured) return;
277
324
  await saveProfile(configured);
278
- console.log(pc.green(`Saved profile: ${configured.label}`));
279
325
  await syncPiConfig(configured);
280
326
  return await runProfile(configured);
281
327
  }
282
328
 
283
- if (selected.startsWith("managed:")) {
284
- const managedSelection = selected.slice("managed:".length);
285
- const separator = managedSelection.indexOf(":");
286
- const backendId = separator === -1 ? managedSelection : managedSelection.slice(0, separator);
287
- const modelId = separator === -1 ? "" : managedSelection.slice(separator + 1);
288
- const model = managedItems.find((m) => m.model.id === modelId && m.backendId === backendId)?.model;
289
- if (!model) throw new Error("Model not found.");
290
- const profile = normalizeProfile({
291
- id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
292
- label: model.label,
293
- backend: backendId,
294
- modelAlias: model.aliasSuggestion,
295
- ...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
296
- ...(backendId === "omlx" ? { omlxModel: model.id } : {}),
297
- });
298
- await saveProfile(profile);
299
- await syncPiConfig(profile);
300
- return await runProfile(profile);
329
+ if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
330
+ }
331
+
332
+ async function printProfileDetails(profile) {
333
+ const backend = backendFor(profile.backend);
334
+ const isManaged = backend.type === "managed-server";
335
+ const piConfigured = await hasPiModel(profile);
336
+ console.log("\n" + renderSection("Profile", renderRows([
337
+ ["ID", pc.cyan(profile.id)],
338
+ ["Label", pc.bold(profile.label)],
339
+ ["Backend", backend.label],
340
+ ["Endpoint", pc.green(profile.baseUrl)],
341
+ ...(!isManaged ? [
342
+ ["Model", profile.modelPath ?? "unknown"],
343
+ ["MMProj", profile.mmprojPath ?? "none"],
344
+ ["Memory", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
345
+ ] : []),
346
+ ["Alias", pc.cyan(profile.modelAlias)],
347
+ ["Pi", piConfigured ? pc.green("configured") : pc.yellow("not synced")],
348
+ ])));
349
+
350
+ if (!isManaged && profile.commandArgv) {
351
+ console.log("\n" + pc.bold("llama-server command"));
352
+ console.log(pc.dim(buildPrettyCommand(profile)));
301
353
  }
302
354
  }
303
355
 
356
+ function printGgufModelDetails(model) {
357
+ console.log("\n" + renderSection("GGUF model", renderRows([
358
+ ["Label", pc.bold(model.label)],
359
+ ["Model", model.path],
360
+ ["MMProj", model.mmprojPath ?? "none"],
361
+ ["Quant", model.quant ?? "unknown"],
362
+ ["Size", formatBytes(model.sizeBytes)],
363
+ ])));
364
+ }
365
+
366
+ function printManagedModelDetails(model, backend) {
367
+ console.log("\n" + renderSection(`${backend.label} model`, renderRows([
368
+ ["Label", pc.bold(model.label)],
369
+ ["ID", pc.cyan(model.id)],
370
+ ["Quant", model.quant ?? "unknown"],
371
+ ["Family", model.family ?? "unknown"],
372
+ ])));
373
+ }
374
+
375
+ function createManagedProfile(model, backendId) {
376
+ return normalizeProfile({
377
+ id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
378
+ label: model.label,
379
+ backend: backendId,
380
+ modelAlias: model.aliasSuggestion,
381
+ ...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
382
+ ...(backendId === "omlx" ? { omlxModel: model.id } : {}),
383
+ });
384
+ }
385
+
304
386
  async function runProfile(profile, options = {}) {
305
387
  const backend = backendFor(profile.backend);
306
388
  const withHarness = options.with ?? "pi";
@@ -382,56 +464,6 @@ async function runProfile(profile, options = {}) {
382
464
  }
383
465
  }
384
466
 
385
- // ── Manage profiles ─────────────────────────────────────────────────────────
386
-
387
- async function manageProfiles(prompt, profiles) {
388
- const choices = profiles.map((p) => ({
389
- value: p.id,
390
- label: p.label,
391
- hint: `${p.modelAlias} · ${p.baseUrl}`,
392
- }));
393
-
394
- const selected = await prompt.choice("Which profile?", choices, choices[0].value);
395
- const profile = await readProfile(selected);
396
- const backend = backendFor(profile.backend);
397
- const isManaged = backend.type === "managed-server";
398
- const piConfigured = await hasPiModel(profile);
399
-
400
- // Show profile details
401
- console.log("");
402
- console.log(renderSection("Profile", renderRows([
403
- ["ID", pc.cyan(profile.id)],
404
- ["Label", pc.bold(profile.label)],
405
- ["Backend", backend.label],
406
- ["Endpoint", pc.green(profile.baseUrl)],
407
- ...(!isManaged ? [
408
- ["Model", profile.modelPath ?? "unknown"],
409
- ["MMProj", profile.mmprojPath ?? "none"],
410
- ["Memory", existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
411
- ] : []),
412
- ["Alias", pc.cyan(profile.modelAlias)],
413
- ["Pi", piConfigured ? pc.green("configured") : pc.yellow("not synced")],
414
- ])));
415
-
416
- if (!isManaged && profile.commandArgv) {
417
- console.log("");
418
- console.log(pc.bold("llama-server command"));
419
- console.log(pc.dim(buildPrettyCommand(profile)));
420
- }
421
-
422
- const action = await prompt.choice("Action", [
423
- { 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" },
424
- { value: "run", label: "Run", hint: "Start server + Pi" },
425
- ...(isManaged ? [] : [{ value: "server", label: "Server only", hint: "Start server, no harness" }]),
426
- { value: "remove", label: "Remove", hint: "Delete profile + Pi config" },
427
- ], "sync");
428
-
429
- if (action === "sync") return await syncPiConfig(profile);
430
- if (action === "run") return await runProfile(profile);
431
- if (action === "server") return await runProfile(profile, { with: "server" });
432
- if (action === "remove") return await removeProfileInteractive(profile.id);
433
- }
434
-
435
467
  async function removeProfileInteractive(id) {
436
468
  const profile = await readProfile(id);
437
469
  if (!process.stdin.isTTY) {
@@ -981,7 +1013,7 @@ function printHelp() {
981
1013
  console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner
982
1014
 
983
1015
  Usage:
984
- offgrid-ai Pick a model and run it
1016
+ offgrid-ai Command center: inspect, set up, run, benchmark, or remove models
985
1017
  offgrid-ai status Show running local models
986
1018
  offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
987
1019
  offgrid-ai uninstall Remove offgrid-ai, clean up PATH, optionally keep profiles