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.
- package/package.json +1 -1
- package/src/cli.mjs +204 -172
package/package.json
CHANGED
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:
|
|
156
|
+
// 6. Interactive: one command center after onboarding.
|
|
155
157
|
startInteractive("offgrid-ai");
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
]
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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 (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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
|