offgrid-ai 0.6.6 → 0.6.7
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/README.md +1 -1
- package/install.sh +1 -1
- package/package.json +4 -2
- package/src/backend-installers.mjs +57 -0
- package/src/cli.mjs +12 -1217
- package/src/commands/benchmark.mjs +4 -0
- package/src/commands/main.mjs +89 -0
- package/src/commands/models.mjs +170 -0
- package/src/commands/onboard.mjs +177 -0
- package/src/commands/run.mjs +146 -0
- package/src/commands/status.mjs +38 -0
- package/src/commands/stop.mjs +64 -0
- package/src/commands/uninstall.mjs +96 -0
- package/src/exec.mjs +31 -0
- package/src/managed.mjs +31 -0
- package/src/model-catalog.mjs +58 -0
- package/src/model-presenters.mjs +190 -0
- package/src/model-summary.mjs +61 -0
- package/src/profile-setup.mjs +16 -7
- package/src/recommendations.mjs +17 -0
package/src/cli.mjs
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, statSync, rmSync } from "node:fs";
|
|
3
|
-
import { basename } from "node:path";
|
|
4
|
-
import { ensureDirs, findLlamaServer, hasHomebrew, DATA_DIR } from "./config.mjs";
|
|
5
|
-
import { scanGgufModels, matchDrafter } from "./scan.mjs";
|
|
6
|
-
import { createProfileFromModel, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
|
|
7
|
-
import { readProfile, saveProfile, deleteProfile, loadProfiles, readCommandArgv } from "./profiles.mjs";
|
|
8
|
-
import { backendFor, BACKENDS } from "./backends.mjs";
|
|
9
|
-
import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, isProfileRunning, profileRuntimeStatus } from "./process.mjs";
|
|
10
|
-
import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from "./harness-pi.mjs";
|
|
11
|
-
import { tailFriendly } from "./logs.mjs";
|
|
12
|
-
import { estimateMemory } from "./estimate.mjs";
|
|
13
|
-
import { pc, formatBytes, renderRows, renderSection, renderCard, humanCapabilitySummary, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
|
|
1
|
+
import { pc, renderRows, renderCard, createPrompt } from "./ui.mjs";
|
|
14
2
|
import { checkForUpdate, currentPackageVersion, detectInvocation, updateCommand, runUpdateCommand } from "./updates.mjs";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
3
|
+
import { mainFlow } from "./commands/main.mjs";
|
|
4
|
+
import { modelsCommand } from "./commands/models.mjs";
|
|
5
|
+
import { runCommand } from "./commands/run.mjs";
|
|
6
|
+
import { statusCommand } from "./commands/status.mjs";
|
|
7
|
+
import { stopCommand } from "./commands/stop.mjs";
|
|
8
|
+
import { benchmarkCommand } from "./commands/benchmark.mjs";
|
|
9
|
+
import { uninstallCommand } from "./commands/uninstall.mjs";
|
|
22
10
|
|
|
23
11
|
async function offerUpdate(argv) {
|
|
24
12
|
const invocation = detectInvocation();
|
|
@@ -37,9 +25,7 @@ async function offerUpdate(argv) {
|
|
|
37
25
|
const shouldUpdate = await prompt.yesNo("Update now?", false);
|
|
38
26
|
if (!shouldUpdate) return false;
|
|
39
27
|
await runUpdateCommand(plan);
|
|
40
|
-
if (plan.mode === "install-global")
|
|
41
|
-
console.log(pc.green("Updated. Run offgrid-ai again to use the new version."));
|
|
42
|
-
}
|
|
28
|
+
if (plan.mode === "install-global") console.log(pc.green("Updated. Run offgrid-ai again to use the new version."));
|
|
43
29
|
return true;
|
|
44
30
|
} finally {
|
|
45
31
|
prompt.close();
|
|
@@ -51,8 +37,8 @@ export async function run(argv) {
|
|
|
51
37
|
if (await offerUpdate(argv)) return;
|
|
52
38
|
return mainFlow();
|
|
53
39
|
}
|
|
54
|
-
const [command] = argv;
|
|
55
40
|
|
|
41
|
+
const [command] = argv;
|
|
56
42
|
if (command === "help" || command === "--help" || command === "-h") return printHelp();
|
|
57
43
|
if (command === "version" || command === "--version" || command === "-v") return printVersion();
|
|
58
44
|
if (command === "models") return modelsCommand(argv.slice(1));
|
|
@@ -61,1202 +47,11 @@ export async function run(argv) {
|
|
|
61
47
|
if (command === "stop") return stopCommand(argv.slice(1));
|
|
62
48
|
if (command === "benchmark") return benchmarkCommand();
|
|
63
49
|
if (command === "uninstall" || command === "--uninstall") return uninstallCommand(argv.slice(1));
|
|
64
|
-
if (command === "--verbose") return mainFlow();
|
|
50
|
+
if (command === "--verbose") return mainFlow();
|
|
65
51
|
|
|
66
52
|
throw new Error(`Unknown command: ${command}. Run offgrid-ai help`);
|
|
67
53
|
}
|
|
68
54
|
|
|
69
|
-
export async function mainFlow() {
|
|
70
|
-
await ensureDirs();
|
|
71
|
-
|
|
72
|
-
if (process.stdin.isTTY) {
|
|
73
|
-
const runtimePrompt = createPrompt();
|
|
74
|
-
try {
|
|
75
|
-
await offerManagedLlamaRuntimeUpdate(runtimePrompt);
|
|
76
|
-
} finally {
|
|
77
|
-
runtimePrompt.close();
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// 1. Check what backends are available
|
|
82
|
-
const llamaBinary = await findLlamaServer();
|
|
83
|
-
const { models: ggufModels, drafters } = await scanGgufModels();
|
|
84
|
-
const managedModels = await scanManagedModels();
|
|
85
|
-
const profiles = await loadProfiles();
|
|
86
|
-
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
87
|
-
const hasAnyModels = ggufModels.length > 0 || managedModels.some((m) => m.models.length > 0);
|
|
88
|
-
|
|
89
|
-
// 2. Check mandatory deps — if anything essential is missing, re-offer onboarding
|
|
90
|
-
const piInstalled = await hasPi();
|
|
91
|
-
const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
|
|
92
|
-
const missingDeps = [];
|
|
93
|
-
if (needsLlama && !llamaBinary) missingDeps.push("llama-server");
|
|
94
|
-
if (!piInstalled) missingDeps.push("Pi");
|
|
95
|
-
if (missingDeps.length > 0) {
|
|
96
|
-
if (!process.stdin.isTTY) {
|
|
97
|
-
throw new Error(`Missing dependencies: ${missingDeps.join(", ")}. Run offgrid-ai interactively to install.`);
|
|
98
|
-
}
|
|
99
|
-
console.log(pc.yellow(`Missing: ${missingDeps.join(", ")}`));
|
|
100
|
-
console.log(pc.dim("offgrid-ai needs these to run. Let's finish setup.\n"));
|
|
101
|
-
return await onboardFlow();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 3. Nothing available at all — need onboarding
|
|
105
|
-
if (!hasAnyBackend && !hasAnyModels && profiles.length === 0) {
|
|
106
|
-
if (!process.stdin.isTTY) {
|
|
107
|
-
throw new Error("No local LLM backends found. Run offgrid-ai interactively to set up.");
|
|
108
|
-
}
|
|
109
|
-
return await onboardFlow();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 4. No models found at all (but backends may exist)
|
|
113
|
-
if (!hasAnyModels && profiles.length === 0) {
|
|
114
|
-
if (!process.stdin.isTTY) {
|
|
115
|
-
throw new Error("No models found. Download a model, then run offgrid-ai.");
|
|
116
|
-
}
|
|
117
|
-
console.log(pc.yellow("No models found."));
|
|
118
|
-
console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
|
|
119
|
-
// Detect which backends are installed
|
|
120
|
-
const ollamaInstalled = await hasOllamaInstalled();
|
|
121
|
-
const omlxInstalled = await hasOmlxInstalled();
|
|
122
|
-
const lmStudioInstalled = existsSync("/Applications/LM Studio.app");
|
|
123
|
-
const hasBackends = llamaBinary || ollamaInstalled || omlxInstalled || lmStudioInstalled;
|
|
124
|
-
if (hasBackends) {
|
|
125
|
-
console.log(pc.bold("Backend status:"));
|
|
126
|
-
console.log(` ${lmStudioInstalled ? pc.green("✓") : pc.red("✗")} LM Studio ${lmStudioInstalled ? "— installed" : "— not installed"}`);
|
|
127
|
-
console.log(` ${ollamaInstalled ? pc.green("✓") : pc.red("✗")} Ollama ${ollamaInstalled ? "— installed" : "— not installed"}`);
|
|
128
|
-
console.log(` ${omlxInstalled ? pc.green("✓") : pc.red("✗")} oMLX ${omlxInstalled ? "— installed" : "— not installed"}`);
|
|
129
|
-
console.log(` ${llamaBinary ? pc.green("✓") : pc.red("✗")} llama-server ${llamaBinary ? "— installed" : "— not installed"}`);
|
|
130
|
-
console.log();
|
|
131
|
-
const model = recommendedModel();
|
|
132
|
-
console.log(pc.bold("Next step — download a model:"));
|
|
133
|
-
if (lmStudioInstalled) {
|
|
134
|
-
console.log(" Open LM Studio → browse models → download");
|
|
135
|
-
console.log(pc.dim(` Recommended: ${model.label}`));
|
|
136
|
-
}
|
|
137
|
-
if (ollamaInstalled) {
|
|
138
|
-
console.log(pc.bold(` ollama pull ${model.ollama}`));
|
|
139
|
-
}
|
|
140
|
-
if (omlxInstalled) {
|
|
141
|
-
console.log(pc.bold(" omlx start"));
|
|
142
|
-
}
|
|
143
|
-
} else {
|
|
144
|
-
console.log(pc.dim("Run offgrid-ai to install a backend and download a model."));
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 5. If not interactive, just show status
|
|
150
|
-
if (!process.stdin.isTTY) {
|
|
151
|
-
await statusCommand();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// 6. Interactive: one command center after onboarding.
|
|
156
|
-
startInteractive("offgrid-ai");
|
|
157
|
-
return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ── Model command center ────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
async function modelsCommand(argv) {
|
|
163
|
-
await ensureDirs();
|
|
164
|
-
const catalog = await loadModelCatalog();
|
|
165
|
-
|
|
166
|
-
if (argv[0]) {
|
|
167
|
-
const profile = await readProfile(argv[0]);
|
|
168
|
-
await printProfileDetails(profile);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (process.stdin.isTTY) startInteractive("offgrid-ai");
|
|
173
|
-
return await modelCommandCenter(catalog);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function modelCommandCenter(initialCatalog) {
|
|
177
|
-
if (!process.stdin.isTTY) {
|
|
178
|
-
const normalized = normalizeCatalog(initialCatalog);
|
|
179
|
-
const allItems = buildCatalogItems(normalized);
|
|
180
|
-
if (allItems.length === 0) return;
|
|
181
|
-
for (const item of allItems) console.log(item.label);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
|
|
186
|
-
const normalized = normalizeCatalog(catalog);
|
|
187
|
-
const allItems = buildCatalogItems(normalized);
|
|
188
|
-
if (allItems.length === 0) { console.log(pc.dim("No models found.")); return; }
|
|
189
|
-
const runningProfilesNow = [];
|
|
190
|
-
for (const profile of normalized.profiles) {
|
|
191
|
-
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
192
|
-
}
|
|
193
|
-
const fileMissingCount = normalized.profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
194
|
-
const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
|
|
195
|
-
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
196
|
-
["Setups", `${normalized.profiles.length} saved`],
|
|
197
|
-
["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
198
|
-
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
199
|
-
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
200
|
-
]), { formatBorder: summaryBorder }));
|
|
201
|
-
|
|
202
|
-
printModelCards(allItems, { runningProfilesNow, drafters: normalized.drafters });
|
|
203
|
-
|
|
204
|
-
const prompt = createPrompt();
|
|
205
|
-
try {
|
|
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) return;
|
|
209
|
-
const item = allItems.find((i) => itemKey(i) === selected);
|
|
210
|
-
if (!item) return;
|
|
211
|
-
|
|
212
|
-
const actions = actionsForItem(item);
|
|
213
|
-
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
214
|
-
if (!action) return;
|
|
215
|
-
await performAction(prompt, action, item);
|
|
216
|
-
} finally {
|
|
217
|
-
prompt.close();
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async function runCommand(argv) {
|
|
222
|
-
await ensureDirs();
|
|
223
|
-
const { positional, options } = parseOptions(argv);
|
|
224
|
-
if (!positional[0]) return await mainFlow();
|
|
225
|
-
const profile = await readProfile(positional[0]);
|
|
226
|
-
return await runProfile(profile, options);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function loadModelCatalog() {
|
|
230
|
-
const [profiles, { models: ggufModels, drafters }, managedModels] = await Promise.all([
|
|
231
|
-
loadProfiles(),
|
|
232
|
-
scanGgufModels(),
|
|
233
|
-
scanManagedModels(),
|
|
234
|
-
]);
|
|
235
|
-
return normalizeCatalog({ profiles, ggufModels, drafters, managedModels });
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function normalizeCatalog(catalog) {
|
|
239
|
-
if (catalog.newModels && catalog.managedItems) return catalog;
|
|
240
|
-
const { profiles, ggufModels, drafters, managedModels } = catalog;
|
|
241
|
-
const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
|
|
242
|
-
const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
|
|
243
|
-
const managedItems = [];
|
|
244
|
-
for (const { backendId, models } of managedModels) {
|
|
245
|
-
const profiledAliases = new Set(
|
|
246
|
-
profiles.filter((p) => p.backend === backendId).map((p) => backendId === "ollama" ? `ollama:${p.ollamaModel ?? p.modelAlias}` : `omlx:${p.omlxModel ?? p.modelAlias}`)
|
|
247
|
-
);
|
|
248
|
-
for (const model of models) {
|
|
249
|
-
if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── Catalog item helpers ───────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
function itemKey(item) {
|
|
258
|
-
if (item.type === "profile") return `profile:${item.profile.id}`;
|
|
259
|
-
if (item.type === "new") return `new:${item.model.path}`;
|
|
260
|
-
return `managed:${item.backendId}:${item.model.id}`;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function buildCatalogItems(normalized) {
|
|
264
|
-
const { profiles, newModels, managedItems, drafters } = normalized;
|
|
265
|
-
return [
|
|
266
|
-
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
|
|
267
|
-
...newModels.map((model) => {
|
|
268
|
-
const drafter = matchDrafter(model.path, drafters);
|
|
269
|
-
return { type: "new", model, label: model.label, drafter };
|
|
270
|
-
}),
|
|
271
|
-
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
|
|
272
|
-
];
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const OPTION_SEPARATOR = pc.dim(" │ ");
|
|
276
|
-
const OPTION_STATUS_WIDTH = 12;
|
|
277
|
-
const OPTION_SOURCE_WIDTH = 14;
|
|
278
|
-
|
|
279
|
-
function optionTag(text, color, width) {
|
|
280
|
-
const padded = String(text).padEnd(width);
|
|
281
|
-
return color ? color(padded) : padded;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function optionStatusTag(kind) {
|
|
285
|
-
const statuses = {
|
|
286
|
-
running: ["RUNNING", pc.green],
|
|
287
|
-
ready: ["READY", pc.green],
|
|
288
|
-
missing: ["FILE MISSING", pc.red],
|
|
289
|
-
setup: ["NEEDS SETUP", pc.yellow],
|
|
290
|
-
};
|
|
291
|
-
const [text, color] = statuses[kind] ?? [kind, pc.dim];
|
|
292
|
-
return optionTag(text, color, OPTION_STATUS_WIDTH);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function optionSourceTag(sourceId, label) {
|
|
296
|
-
const colors = {
|
|
297
|
-
"llama-cpp": pc.cyan,
|
|
298
|
-
"llama-cpp-mtp": pc.blue,
|
|
299
|
-
ollama: pc.green,
|
|
300
|
-
omlx: pc.magenta,
|
|
301
|
-
gguf: pc.cyan,
|
|
302
|
-
};
|
|
303
|
-
return optionTag(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function optionLabel({ status, source, name, details = [] }) {
|
|
307
|
-
return [status, source, pc.bold(name), ...details].filter(Boolean).join(OPTION_SEPARATOR);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function modelSelectOption(item, { runningProfilesNow }) {
|
|
311
|
-
if (item.type === "profile") {
|
|
312
|
-
const { profile } = item;
|
|
313
|
-
const backend = backendFor(profile.backend);
|
|
314
|
-
const running = runningProfilesNow.some((r) => r.id === profile.id);
|
|
315
|
-
const fileMissing = item.fileMissing;
|
|
316
|
-
const status = optionStatusTag(fileMissing ? "missing" : running ? "running" : "ready");
|
|
317
|
-
const source = optionSourceTag(profile.backend, backend.label);
|
|
318
|
-
const label = optionLabel({ status, source, name: profile.label });
|
|
319
|
-
return { value: itemKey(item), label };
|
|
320
|
-
}
|
|
321
|
-
if (item.type === "new") {
|
|
322
|
-
const { model } = item;
|
|
323
|
-
const label = optionLabel({
|
|
324
|
-
status: optionStatusTag("setup"),
|
|
325
|
-
source: optionSourceTag("gguf", "GGUF file"),
|
|
326
|
-
name: model.label,
|
|
327
|
-
});
|
|
328
|
-
return { value: itemKey(item), label };
|
|
329
|
-
}
|
|
330
|
-
// managed
|
|
331
|
-
const { model, backendId } = item;
|
|
332
|
-
const backend = BACKENDS[backendId];
|
|
333
|
-
const label = optionLabel({
|
|
334
|
-
status: optionStatusTag("setup"),
|
|
335
|
-
source: optionSourceTag(backendId, backend.label),
|
|
336
|
-
name: model.label,
|
|
337
|
-
});
|
|
338
|
-
return { value: itemKey(item), label };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function actionsForItem(item) {
|
|
342
|
-
if (item.type === "profile") {
|
|
343
|
-
const actions = [
|
|
344
|
-
{ value: "run", label: "Start chatting", hint: "Launch and open Pi" },
|
|
345
|
-
{ value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
|
|
346
|
-
{ value: "inspect", label: "Details", hint: "Paths, ports, flags" },
|
|
347
|
-
];
|
|
348
|
-
// Benchmark is offered if repo is linked and model is local
|
|
349
|
-
const backend = backendFor(item.profile.backend);
|
|
350
|
-
if (backend.type === "local-server" || backend.type === "managed-server") {
|
|
351
|
-
actions.push({ value: "benchmark", label: "Benchmark", hint: "Prepare a benchmark run" });
|
|
352
|
-
}
|
|
353
|
-
if (!item.fileMissing) {
|
|
354
|
-
actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
|
|
355
|
-
}
|
|
356
|
-
return actions;
|
|
357
|
-
}
|
|
358
|
-
if (item.type === "new") {
|
|
359
|
-
return [
|
|
360
|
-
{ value: "setup", label: "Set up", hint: "Configure and save" },
|
|
361
|
-
{ value: "inspect", label: "Details", hint: "Model info" },
|
|
362
|
-
];
|
|
363
|
-
}
|
|
364
|
-
// managed
|
|
365
|
-
return [
|
|
366
|
-
{ value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
|
|
367
|
-
{ value: "inspect", label: "Details", hint: "Model info" },
|
|
368
|
-
];
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function isProfileFileMissing(profile) {
|
|
372
|
-
const backend = backendFor(profile.backend);
|
|
373
|
-
if (backend.type === "managed-server") return false;
|
|
374
|
-
if (!profile.modelPath) return true;
|
|
375
|
-
return !existsSync(profile.modelPath);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function printModelCards(items, { runningProfilesNow, drafters }) {
|
|
379
|
-
// Group items by type for section headers
|
|
380
|
-
const profiles = items.filter((i) => i.type === "profile");
|
|
381
|
-
const newModels = items.filter((i) => i.type === "new");
|
|
382
|
-
const managed = items.filter((i) => i.type === "managed");
|
|
383
|
-
|
|
384
|
-
if (profiles.length > 0) {
|
|
385
|
-
console.log("\n" + pc.bold("Ready to chat"));
|
|
386
|
-
for (const item of profiles) {
|
|
387
|
-
const { profile } = item;
|
|
388
|
-
const backend = backendFor(profile.backend);
|
|
389
|
-
const running = runningProfilesNow.some((r) => r.id === profile.id);
|
|
390
|
-
const fileMissing = item.fileMissing;
|
|
391
|
-
const caps = profile.capabilities ?? {};
|
|
392
|
-
let status;
|
|
393
|
-
if (fileMissing) status = pc.red("File missing");
|
|
394
|
-
else if (running) status = pc.green("Running now");
|
|
395
|
-
else status = "Ready";
|
|
396
|
-
const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
397
|
-
// MTP: enabled vs available
|
|
398
|
-
let mtpLabel;
|
|
399
|
-
if (profile.drafterPath) mtpLabel = pc.green("MTP enabled");
|
|
400
|
-
else if (drafters ? matchDrafter(profile.modelPath, drafters) : null) mtpLabel = pc.yellow("MTP available");
|
|
401
|
-
else if (caps.architecture === "gemma4") mtpLabel = pc.yellow("MTP: needs drafter");
|
|
402
|
-
const detailParts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps)];
|
|
403
|
-
if (mtpLabel) detailParts.push(mtpLabel);
|
|
404
|
-
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
405
|
-
if (ctxLabel) detailParts.push(ctxLabel);
|
|
406
|
-
console.log(renderCard(profile.label, renderRows([
|
|
407
|
-
["Status", status],
|
|
408
|
-
["Details", detailParts.join(pc.dim(" · "))],
|
|
409
|
-
["Runs with", backend.label],
|
|
410
|
-
]), { formatBorder: border }));
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (newModels.length > 0) {
|
|
415
|
-
console.log("\n" + pc.bold("Needs setup"));
|
|
416
|
-
for (const item of newModels) {
|
|
417
|
-
const { model, drafter } = item;
|
|
418
|
-
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
419
|
-
const mtpAvailable = caps.mtp || Boolean(drafter);
|
|
420
|
-
const mtpLabel = mtpAvailable
|
|
421
|
-
? pc.green("MTP ✓")
|
|
422
|
-
: (caps.architecture === "gemma4")
|
|
423
|
-
? pc.yellow("MTP: needs drafter")
|
|
424
|
-
: null;
|
|
425
|
-
const detailParts = [humanCapabilitySummary(caps)];
|
|
426
|
-
if (mtpLabel) detailParts.push(mtpLabel);
|
|
427
|
-
detailParts.push(formatBytes(model.sizeBytes));
|
|
428
|
-
console.log(renderCard(model.label, renderRows([
|
|
429
|
-
["Status", pc.yellow("Needs setup")],
|
|
430
|
-
["Details", detailParts.join(pc.dim(" · "))],
|
|
431
|
-
]), { formatBorder: pc.yellow }));
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
for (const beId of ["ollama", "omlx"]) {
|
|
436
|
-
const managedForBackend = managed.filter((i) => i.backendId === beId);
|
|
437
|
-
if (managedForBackend.length === 0) continue;
|
|
438
|
-
const be = BACKENDS[beId];
|
|
439
|
-
console.log("\n" + pc.bold(`Via ${be.label}`));
|
|
440
|
-
for (const item of managedForBackend) {
|
|
441
|
-
console.log(renderCard(item.model.label, renderRows([
|
|
442
|
-
["Details", [item.model.id, item.model.quant].filter(Boolean).join(pc.dim(" · "))],
|
|
443
|
-
]), { formatBorder: pc.dim }));
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
async function performAction(prompt, action, item) {
|
|
449
|
-
if (action === "inspect") {
|
|
450
|
-
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|
|
451
|
-
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
452
|
-
return printGgufModelDetails(item.model, item.drafter);
|
|
453
|
-
}
|
|
454
|
-
if (action === "benchmark") {
|
|
455
|
-
const { benchmarkForProfile } = await import("./benchmark.mjs");
|
|
456
|
-
if (item.type === "profile") return await benchmarkForProfile(await readProfile(item.profile.id));
|
|
457
|
-
// For new/managed models, go through the full flow
|
|
458
|
-
const { benchmarkFlow } = await import("./benchmark.mjs");
|
|
459
|
-
return await benchmarkFlow();
|
|
460
|
-
}
|
|
461
|
-
if (action === "run") {
|
|
462
|
-
if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
|
|
463
|
-
// Shouldn't reach here for new/managed, but handle gracefully
|
|
464
|
-
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
465
|
-
const configured = await configureLocalProfile(prompt, profile);
|
|
466
|
-
if (!configured) return;
|
|
467
|
-
await saveProfile(configured);
|
|
468
|
-
await syncPiConfig(configured);
|
|
469
|
-
return await runProfile(configured);
|
|
470
|
-
}
|
|
471
|
-
if (action === "reconfigure" || action === "setup") {
|
|
472
|
-
if (item.type === "profile") {
|
|
473
|
-
const profile = await readProfile(item.profile.id);
|
|
474
|
-
const configured = await configureLocalProfile(prompt, profile);
|
|
475
|
-
if (!configured) return;
|
|
476
|
-
await saveProfile(configured);
|
|
477
|
-
return await syncPiConfig(configured);
|
|
478
|
-
}
|
|
479
|
-
if (item.type === "managed") {
|
|
480
|
-
const profile = createManagedProfile(item.model, item.backendId);
|
|
481
|
-
await saveProfile(profile);
|
|
482
|
-
return await syncPiConfig(profile);
|
|
483
|
-
}
|
|
484
|
-
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
485
|
-
const configured = await configureLocalProfile(prompt, profile);
|
|
486
|
-
if (!configured) return;
|
|
487
|
-
await saveProfile(configured);
|
|
488
|
-
return await syncPiConfig(configured);
|
|
489
|
-
}
|
|
490
|
-
if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
async function printProfileDetails(profile) {
|
|
495
|
-
const backend = backendFor(profile.backend);
|
|
496
|
-
const isManaged = backend.type === "managed-server";
|
|
497
|
-
const running = await isProfileRunning(profile);
|
|
498
|
-
const fileMissing = !isManaged && isProfileFileMissing(profile);
|
|
499
|
-
const statusRow = fileMissing
|
|
500
|
-
? pc.red("File missing")
|
|
501
|
-
: running ? pc.green("Running now") : "Ready";
|
|
502
|
-
const mtpStatus = profile.drafterPath
|
|
503
|
-
? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`)
|
|
504
|
-
: (profile.capabilities?.architecture === "gemma4")
|
|
505
|
-
? pc.yellow("MTP available — download a drafter model to enable 2× speedup")
|
|
506
|
-
: null;
|
|
507
|
-
const detailParts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(profile.capabilities ?? {})];
|
|
508
|
-
if (mtpStatus) detailParts.push(mtpStatus);
|
|
509
|
-
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
510
|
-
if (ctxLabel) detailParts.push(ctxLabel);
|
|
511
|
-
const overviewRows = [
|
|
512
|
-
["Name", pc.bold(profile.label)],
|
|
513
|
-
["Status", statusRow],
|
|
514
|
-
["Details", detailParts.join(pc.dim(" · "))],
|
|
515
|
-
["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
|
|
516
|
-
];
|
|
517
|
-
console.log("\n" + renderSection("Model overview", renderRows(overviewRows)));
|
|
518
|
-
|
|
519
|
-
const detailRows = [
|
|
520
|
-
["Setup ID", profile.id],
|
|
521
|
-
["Runs with", backend.label],
|
|
522
|
-
["Model alias", profile.modelAlias],
|
|
523
|
-
...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
|
|
524
|
-
];
|
|
525
|
-
if (!isManaged) {
|
|
526
|
-
detailRows.push(
|
|
527
|
-
["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
|
|
528
|
-
["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
|
|
529
|
-
["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
|
|
530
|
-
);
|
|
531
|
-
if (profile.drafterPath) {
|
|
532
|
-
detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
536
|
-
|
|
537
|
-
if (fileMissing) {
|
|
538
|
-
console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
if (!isManaged && profile.commandArgv) {
|
|
542
|
-
const commandArgv = await readCommandArgv(profile);
|
|
543
|
-
console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand({ ...profile, commandArgv })), { columns: 120 }));
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function printGgufModelDetails(model, drafter) {
|
|
548
|
-
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
549
|
-
const mtpAvailable = caps.mtp || Boolean(drafter);
|
|
550
|
-
const mtpLabel = mtpAvailable
|
|
551
|
-
? pc.green("MTP ✓")
|
|
552
|
-
: (caps.architecture === "gemma4")
|
|
553
|
-
? pc.yellow("MTP: needs drafter")
|
|
554
|
-
: null;
|
|
555
|
-
const detailParts = [humanCapabilitySummary(caps)];
|
|
556
|
-
if (mtpLabel) detailParts.push(mtpLabel);
|
|
557
|
-
const ctxLabel = caps.ctxSize ? `${(caps.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
558
|
-
if (ctxLabel) detailParts.push(ctxLabel);
|
|
559
|
-
detailParts.push(formatBytes(model.sizeBytes));
|
|
560
|
-
const overviewRows = [
|
|
561
|
-
["Name", pc.bold(model.label)],
|
|
562
|
-
["Status", pc.yellow("Needs one-time setup")],
|
|
563
|
-
["Details", detailParts.join(pc.dim(" · "))],
|
|
564
|
-
];
|
|
565
|
-
console.log("\n" + renderSection("Downloaded model", renderRows(overviewRows)));
|
|
566
|
-
const detailRows = [
|
|
567
|
-
["Local file", model.path],
|
|
568
|
-
["Vision file", model.mmprojPath ?? "none"],
|
|
569
|
-
["Detected", capabilitySummary(caps)],
|
|
570
|
-
["Quant", model.quant ?? "unknown"],
|
|
571
|
-
];
|
|
572
|
-
if (drafter) {
|
|
573
|
-
detailRows.push(["Drafter", drafter.path], ["Drafter size", formatBytes(drafter.sizeBytes)]);
|
|
574
|
-
}
|
|
575
|
-
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
function printManagedModelDetails(model, backend) {
|
|
579
|
-
console.log("\n" + renderSection(`${backend.label} model`, renderRows([
|
|
580
|
-
["Name", pc.bold(model.label)],
|
|
581
|
-
["Status", pc.green(`Local model via ${backend.label}`)],
|
|
582
|
-
["Model ID", pc.cyan(model.id)],
|
|
583
|
-
["Quant", model.quant ?? "unknown"],
|
|
584
|
-
["Family", model.family ?? "unknown"],
|
|
585
|
-
])));
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function capabilitySummary(caps) {
|
|
589
|
-
const parts = [];
|
|
590
|
-
if (caps.architecture) parts.push(caps.architecture);
|
|
591
|
-
if (caps.quant) parts.push(caps.quant);
|
|
592
|
-
if (caps.mtp) parts.push("MTP");
|
|
593
|
-
if (caps.qat) parts.push("QAT");
|
|
594
|
-
|
|
595
|
-
if (caps.thinking) parts.push("thinking");
|
|
596
|
-
if (caps.vision) parts.push("vision");
|
|
597
|
-
return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function isUnsupportedMmprojError(err, profile) {
|
|
601
|
-
const message = String(err?.message ?? "");
|
|
602
|
-
return Boolean(profile.mmprojPath && /unknown projector type|failed to load multimodal model|failed to load CLIP model/i.test(message));
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function textOnlyProfile(profile) {
|
|
606
|
-
return normalizeProfile({
|
|
607
|
-
...profile,
|
|
608
|
-
mmprojPath: null,
|
|
609
|
-
disabledMmprojPath: profile.disabledMmprojPath ?? profile.mmprojPath,
|
|
610
|
-
capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: "unsupported-mmproj" },
|
|
611
|
-
commandArgv: removeCommandOption(profile.commandArgv ?? [], "--mmproj"),
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function removeCommandOption(argv, flag) {
|
|
616
|
-
const next = [];
|
|
617
|
-
for (let i = 0; i < argv.length; i++) {
|
|
618
|
-
if (argv[i] === flag) {
|
|
619
|
-
if (argv[i + 1] && !argv[i + 1].startsWith("--")) i += 1;
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
next.push(argv[i]);
|
|
623
|
-
}
|
|
624
|
-
return next;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function createManagedProfile(model, backendId) {
|
|
628
|
-
return normalizeProfile({
|
|
629
|
-
id: `${backendId}-${sanitizeProfileId(model.id)}`,
|
|
630
|
-
label: model.label,
|
|
631
|
-
backend: backendId,
|
|
632
|
-
modelAlias: model.aliasSuggestion,
|
|
633
|
-
...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
|
|
634
|
-
...(backendId === "omlx" ? { omlxModel: model.id } : {}),
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
async function runProfile(profile, options = {}) {
|
|
639
|
-
const backend = backendFor(profile.backend);
|
|
640
|
-
const withHarness = options.with ?? "pi";
|
|
641
|
-
|
|
642
|
-
// Check harness
|
|
643
|
-
if (withHarness === "pi") {
|
|
644
|
-
const piInstalled = await hasPi();
|
|
645
|
-
if (!piInstalled) {
|
|
646
|
-
console.log(pc.yellow("Pi is not installed. Run with --with server, or install Pi from https://pi.app"));
|
|
647
|
-
console.log(pc.dim("Starting server only..."));
|
|
648
|
-
return await runProfile(profile, { ...options, with: "server" });
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const isManaged = backend.type === "managed-server";
|
|
653
|
-
|
|
654
|
-
// Start/verify server
|
|
655
|
-
if (isManaged) {
|
|
656
|
-
if (!(await serverReady(profile.baseUrl))) {
|
|
657
|
-
throw new Error(`${backend.label} is not running at ${profile.baseUrl}. Start it and try again.`);
|
|
658
|
-
}
|
|
659
|
-
console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
|
|
660
|
-
} else {
|
|
661
|
-
const ready = await serverReady(profile.baseUrl);
|
|
662
|
-
if (ready) {
|
|
663
|
-
const match = await serverMatchesProfile(profile);
|
|
664
|
-
if (!match.matches) {
|
|
665
|
-
throw new Error(`A different server is already responding at ${profile.baseUrl}. ${match.reason}. Stop it with offgrid-ai stop --all, or choose a different port.`);
|
|
666
|
-
}
|
|
667
|
-
console.log(pc.green(`[ready] Reusing server at ${profile.baseUrl}`));
|
|
668
|
-
} else {
|
|
669
|
-
console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
|
|
670
|
-
let state;
|
|
671
|
-
try {
|
|
672
|
-
state = await startServer(profile);
|
|
673
|
-
const tail = state?.rawLogPath ? tailFriendly(state.rawLogPath, state.friendlyLogPath) : { stop() {} };
|
|
674
|
-
try {
|
|
675
|
-
await waitForReady(profile, state?.pid, state?.rawLogPath);
|
|
676
|
-
console.log(pc.green(`[ready] ${profile.baseUrl}/models`));
|
|
677
|
-
} finally {
|
|
678
|
-
tail.stop();
|
|
679
|
-
}
|
|
680
|
-
} catch (err) {
|
|
681
|
-
// Clean up orphaned server process if startup failed
|
|
682
|
-
if (state?.pid) {
|
|
683
|
-
try { await stopProfile(profile); } catch { /* best effort */ }
|
|
684
|
-
}
|
|
685
|
-
if (!options.textOnlyRetry && isUnsupportedMmprojError(err, profile)) {
|
|
686
|
-
console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
|
|
687
|
-
console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
|
|
688
|
-
const textOnly = textOnlyProfile(profile);
|
|
689
|
-
await saveProfile(textOnly, { writeCommand: true });
|
|
690
|
-
return await runProfile(textOnly, { ...options, textOnlyRetry: true });
|
|
691
|
-
}
|
|
692
|
-
throw err;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
// Show memory estimate for local models
|
|
698
|
-
if (!isManaged && profile.modelPath && existsSync(profile.modelPath)) {
|
|
699
|
-
try {
|
|
700
|
-
const est = estimateMemory(profile.modelPath, profile.mmprojPath, profile.drafterPath, profile.flags);
|
|
701
|
-
const rows = [
|
|
702
|
-
["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
|
|
703
|
-
["Model file", formatBytes(est.modelBytes)],
|
|
704
|
-
];
|
|
705
|
-
if (est.draftBytes) rows.push(["Drafter", formatBytes(est.draftBytes)]);
|
|
706
|
-
if (est.mmprojBytes) rows.push(["Vision projector", formatBytes(est.mmprojBytes)]);
|
|
707
|
-
rows.push(["Conversation memory", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"]);
|
|
708
|
-
console.log(renderSection("Memory estimate", renderRows(rows)));
|
|
709
|
-
} catch { /* estimate failed, skip */ }
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// Launch harness
|
|
713
|
-
if (withHarness === "pi") {
|
|
714
|
-
if (!(await hasPiModel(profile))) await syncPiConfig(profile);
|
|
715
|
-
try {
|
|
716
|
-
await launchPi(profile);
|
|
717
|
-
} finally {
|
|
718
|
-
if (!isManaged && !options["keep-server"]) {
|
|
719
|
-
const result = await stopProfile(profile);
|
|
720
|
-
console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
} else {
|
|
724
|
-
if (!isManaged) {
|
|
725
|
-
console.log(pc.dim(`Server running at ${profile.baseUrl}`));
|
|
726
|
-
console.log(pc.dim(`Stop with: offgrid-ai stop ${profile.id}`));
|
|
727
|
-
} else {
|
|
728
|
-
console.log(pc.dim(`${backend.label} is a managed service — offgrid-ai does not stop it.`));
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
async function removeProfileInteractive(id) {
|
|
734
|
-
const profile = await readProfile(id);
|
|
735
|
-
if (!process.stdin.isTTY) {
|
|
736
|
-
console.log(pc.red(`Use --force to remove ${id} non-interactively.`));
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
const prompt = createPrompt();
|
|
740
|
-
try {
|
|
741
|
-
const confirmed = await prompt.yesNo(`Remove ${profile.label} (${profile.id})?`, false);
|
|
742
|
-
if (!confirmed) { console.log(pc.dim("Cancelled.")); return; }
|
|
743
|
-
} finally {
|
|
744
|
-
prompt.close();
|
|
745
|
-
}
|
|
746
|
-
if (await isProfileRunning(profile)) {
|
|
747
|
-
console.log(pc.yellow("Stopping running server..."));
|
|
748
|
-
await stopProfile(profile);
|
|
749
|
-
}
|
|
750
|
-
await removeFromPiConfig(profile);
|
|
751
|
-
await deleteProfile(id);
|
|
752
|
-
console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// ── Benchmark (stub) ────────────────────────────────────────────────────────
|
|
756
|
-
|
|
757
|
-
// ── Status ──────────────────────────────────────────────────────────────────
|
|
758
|
-
|
|
759
|
-
async function benchmarkCommand() {
|
|
760
|
-
const { benchmarkFlow } = await import("./benchmark.mjs");
|
|
761
|
-
return await benchmarkFlow();
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
async function statusCommand() {
|
|
765
|
-
await ensureDirs();
|
|
766
|
-
const profiles = await loadProfiles();
|
|
767
|
-
|
|
768
|
-
// Check all profiles for running status
|
|
769
|
-
const statuses = [];
|
|
770
|
-
for (const profile of profiles) {
|
|
771
|
-
const status = await profileRuntimeStatus(profile);
|
|
772
|
-
statuses.push({ profile, status });
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const running = statuses.filter((s) => s.status.running);
|
|
776
|
-
|
|
777
|
-
if (running.length === 0) {
|
|
778
|
-
console.log(renderCard("Status", renderRows([
|
|
779
|
-
["Running now", pc.dim("none")],
|
|
780
|
-
["Ready setups", profiles.length > 0 ? String(profiles.length) : pc.dim("none")],
|
|
781
|
-
["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : pc.yellow("Run offgrid-ai to set up a model")],
|
|
782
|
-
]), { formatBorder: pc.dim }));
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
console.log(renderCard("Status", renderRows([
|
|
787
|
-
["Running now", pc.green(`${running.length} model${running.length === 1 ? "" : "s"}`)],
|
|
788
|
-
["Stop", "offgrid-ai stop"],
|
|
789
|
-
]), { formatBorder: pc.green }));
|
|
790
|
-
for (const { profile, status } of running) {
|
|
791
|
-
const backend = backendFor(profile.backend);
|
|
792
|
-
console.log("\n" + renderCard(profile.label, renderRows([
|
|
793
|
-
["Status", status.ready ? pc.green("Ready") : pc.yellow("Starting up")],
|
|
794
|
-
["Runs with", backend.label],
|
|
795
|
-
["Process", `pid ${status.pid}`],
|
|
796
|
-
["Server", profile.baseUrl],
|
|
797
|
-
]), { formatBorder: status.ready ? pc.green : pc.yellow }));
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// ── Stop ────────────────────────────────────────────────────────────────────
|
|
802
|
-
|
|
803
|
-
async function stopCommand(argv) {
|
|
804
|
-
await ensureDirs();
|
|
805
|
-
const { positional, options } = parseOptions(argv);
|
|
806
|
-
|
|
807
|
-
if (options.all) return stopAll();
|
|
808
|
-
if (positional[0]) return stopOne(positional[0]);
|
|
809
|
-
|
|
810
|
-
// Interactive
|
|
811
|
-
const running = await runningProfiles();
|
|
812
|
-
if (running.length === 0) {
|
|
813
|
-
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
814
|
-
return;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
if (!process.stdin.isTTY) {
|
|
818
|
-
for (const { profile, status } of running) {
|
|
819
|
-
console.log(` ${pc.green("●")} ${pc.bold(profile.label)} · pid ${status.pid}`);
|
|
820
|
-
}
|
|
821
|
-
console.log(pc.dim("Stop with: offgrid-ai stop <id>"));
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
startInteractive("offgrid-ai stop");
|
|
826
|
-
const prompt = createPrompt();
|
|
827
|
-
try {
|
|
828
|
-
const choices = running.map(({ profile, status }) => ({
|
|
829
|
-
value: profile.id, label: profile.label, hint: `pid ${status.pid} · ${profile.baseUrl}`,
|
|
830
|
-
}));
|
|
831
|
-
if (running.length > 1) choices.unshift({ value: "__all", label: "Stop all", hint: `${running.length} servers` });
|
|
832
|
-
choices.push({ value: "__cancel", label: "Cancel" });
|
|
833
|
-
|
|
834
|
-
const selected = await prompt.choice("Stop", choices, choices[0].value);
|
|
835
|
-
if (selected === "__cancel") return;
|
|
836
|
-
|
|
837
|
-
const targets = selected === "__all" ? running : running.filter((i) => i.profile.id === selected);
|
|
838
|
-
for (const { profile } of targets) {
|
|
839
|
-
const result = await stopProfile(profile);
|
|
840
|
-
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
841
|
-
}
|
|
842
|
-
} finally {
|
|
843
|
-
prompt.close();
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
async function stopOne(id) {
|
|
848
|
-
const profile = await readProfile(id);
|
|
849
|
-
const result = await stopProfile(profile);
|
|
850
|
-
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async function stopAll() {
|
|
854
|
-
const running = await runningProfiles();
|
|
855
|
-
if (running.length === 0) {
|
|
856
|
-
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
for (const { profile } of running) {
|
|
860
|
-
const result = await stopProfile(profile);
|
|
861
|
-
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
async function runningProfiles() {
|
|
866
|
-
const profiles = await loadProfiles();
|
|
867
|
-
const statuses = await Promise.all(profiles.map(async (profile) => ({ profile, status: await profileRuntimeStatus(profile) })));
|
|
868
|
-
return statuses.filter((i) => i.status.running);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// ── Onboarding ──────────────────────────────────────────────────────────────
|
|
872
|
-
|
|
873
|
-
// ── Model recommendations by RAM ───────────────────────────────────────
|
|
874
|
-
// Tier → { lms: [...], ollama: string, label }
|
|
875
|
-
// lms entries are tried in order (first staff-pick match wins, or @quant forces it)
|
|
876
|
-
const MODEL_TIERS = [
|
|
877
|
-
{ maxGB: 8, lms: "google/gemma-4-e2b", ollama: "gemma4:e2b", label: "Gemma 4 E2B (2B effective)" },
|
|
878
|
-
{ maxGB: 16, lms: "google/gemma-4-e4b", ollama: "gemma4:e4b", label: "Gemma 4 E4B (4B effective)" },
|
|
879
|
-
{ maxGB: 32, lms: "qwen/qwen3.5-9b", ollama: "qwen3.5:9b-q4_K_M", label: "Qwen 3.5 9B" },
|
|
880
|
-
{ maxGB: Infinity, lms: "qwen/qwen3.6-35b-a3b", ollama: "qwen3.6:35b-a3b", label: "Qwen 3.6 35B-A3B" },
|
|
881
|
-
];
|
|
882
|
-
|
|
883
|
-
function recommendedModel() {
|
|
884
|
-
const gb = totalmem() / (1024 ** 3);
|
|
885
|
-
const tier = MODEL_TIERS.find(t => gb <= t.maxGB) || MODEL_TIERS[MODEL_TIERS.length - 1];
|
|
886
|
-
return tier;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
async function onboardFlow() {
|
|
890
|
-
startInteractive("offgrid-ai setup");
|
|
891
|
-
const prompt = createPrompt();
|
|
892
|
-
const verbose = process.argv.includes("--verbose");
|
|
893
|
-
|
|
894
|
-
const { spawn } = await import("node:child_process");
|
|
895
|
-
|
|
896
|
-
/** Run a command. Verbose: stream output. Quiet: show only label + result. */
|
|
897
|
-
const run = (cmd, args, label) => new Promise((resolve, reject) => {
|
|
898
|
-
if (verbose) {
|
|
899
|
-
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
900
|
-
child.on("close", (code) => {
|
|
901
|
-
if (code === 0) resolve();
|
|
902
|
-
else reject(new Error(`${label || cmd} exited with code ${code}`));
|
|
903
|
-
});
|
|
904
|
-
child.on("error", (err) => reject(err));
|
|
905
|
-
} else {
|
|
906
|
-
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
907
|
-
let stderr = "";
|
|
908
|
-
child.stderr.on("data", (d) => { stderr += d; });
|
|
909
|
-
child.on("close", (code) => {
|
|
910
|
-
if (code === 0) resolve();
|
|
911
|
-
else reject(new Error(stderr.split("\n").filter(l => l.trim()).slice(-3).join("\n") || `${label || cmd} exited with code ${code}`));
|
|
912
|
-
});
|
|
913
|
-
child.on("error", (err) => reject(err));
|
|
914
|
-
}
|
|
915
|
-
});
|
|
916
|
-
try {
|
|
917
|
-
console.log(pc.bold("Welcome to offgrid-ai!"));
|
|
918
|
-
console.log(pc.dim("Let's make sure you have everything you need to run local models.\n"));
|
|
919
|
-
|
|
920
|
-
// 1. llama.cpp runtime for local GGUF models
|
|
921
|
-
let llamaBinary = await findLlamaServer();
|
|
922
|
-
if (!llamaBinary) {
|
|
923
|
-
console.log(renderSection("llama.cpp runtime", renderRows([
|
|
924
|
-
["Status", pc.yellow("not installed")],
|
|
925
|
-
["Used for", "local GGUF models"],
|
|
926
|
-
["Install", "managed by offgrid-ai under ~/.offgrid-ai/runtime"],
|
|
927
|
-
]), { formatBorder: pc.cyan }));
|
|
928
|
-
await offerManagedLlamaRuntimeUpdate(prompt);
|
|
929
|
-
llamaBinary = await findLlamaServer();
|
|
930
|
-
if (!llamaBinary) {
|
|
931
|
-
console.log(pc.yellow("Skipping llama.cpp for now. You can still use Ollama/oMLX, or run offgrid-ai again to install the managed runtime."));
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
if (llamaBinary) console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
|
|
935
|
-
|
|
936
|
-
const ensureHomebrewFor = async (label) => {
|
|
937
|
-
if (await hasHomebrew()) return true;
|
|
938
|
-
const install = await prompt.yesNo(`Homebrew is needed to install ${label}. Install Homebrew now?`, true);
|
|
939
|
-
if (!install) {
|
|
940
|
-
console.log(pc.dim(`Install ${label} manually, or install Homebrew from https://brew.sh and run offgrid-ai again.`));
|
|
941
|
-
return false;
|
|
942
|
-
}
|
|
943
|
-
console.log(pc.cyan("Installing Homebrew..."));
|
|
944
|
-
try {
|
|
945
|
-
await run("/bin/bash", ["-c", "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""], "Homebrew");
|
|
946
|
-
const brewPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
947
|
-
for (const p of brewPaths) {
|
|
948
|
-
if (existsSync(p)) {
|
|
949
|
-
process.env.PATH = `${p}:${process.env.PATH}`;
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
} catch {
|
|
954
|
-
console.log(pc.red("✗ Homebrew installation failed."));
|
|
955
|
-
console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
|
|
956
|
-
return false;
|
|
957
|
-
}
|
|
958
|
-
if (!(await hasHomebrew())) {
|
|
959
|
-
console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
|
|
960
|
-
return false;
|
|
961
|
-
}
|
|
962
|
-
console.log(pc.green("✓ Homebrew found"));
|
|
963
|
-
return true;
|
|
964
|
-
};
|
|
965
|
-
|
|
966
|
-
// 2. Pi coding agent
|
|
967
|
-
const piInstalled = await hasPi();
|
|
968
|
-
if (!piInstalled) {
|
|
969
|
-
const install = await prompt.yesNo("Pi coding agent is required to chat with models. Install via npm?", true);
|
|
970
|
-
if (!install) {
|
|
971
|
-
console.log(pc.red("offgrid-ai needs Pi to run models."));
|
|
972
|
-
console.log(pc.dim("Install it manually: npm install -g --ignore-scripts @earendil-works/pi-coding-agent"));
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
console.log(pc.cyan("Installing Pi..."));
|
|
976
|
-
try {
|
|
977
|
-
await run("npm", ["install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], "Pi");
|
|
978
|
-
} catch {
|
|
979
|
-
console.log(pc.red("✗ Failed to install Pi."));
|
|
980
|
-
console.log(pc.dim("Install it manually: npm install -g --ignore-scripts @earendil-works/pi-coding-agent"));
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
if (!(await hasPi())) {
|
|
984
|
-
console.log(pc.yellow("Pi was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
console.log(pc.green("✓ Pi found"));
|
|
989
|
-
|
|
990
|
-
// 4. Model backends — at least one is mandatory
|
|
991
|
-
const { models: ggufModels } = await scanGgufModels();
|
|
992
|
-
const managedModels = await scanManagedModels();
|
|
993
|
-
const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
|
|
994
|
-
const hasModels = ggufModels.length > 0 || totalManaged > 0;
|
|
995
|
-
|
|
996
|
-
if (hasModels) {
|
|
997
|
-
// They already have models — show what was found
|
|
998
|
-
if (ggufModels.length > 0) {
|
|
999
|
-
console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
|
|
1000
|
-
if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
|
|
1001
|
-
}
|
|
1002
|
-
for (const { backendId, models } of managedModels) {
|
|
1003
|
-
if (models.length > 0) {
|
|
1004
|
-
console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
} else {
|
|
1008
|
-
// No models found — offer to install backends that come with models
|
|
1009
|
-
console.log(pc.yellow("\nNo models found."));
|
|
1010
|
-
console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
|
|
1011
|
-
|
|
1012
|
-
const backendChoice = await prompt.choice("Install a model backend?", [
|
|
1013
|
-
{ value: "lmstudio", label: "LM Studio (recommended)", hint: "brew install --cask lm-studio — visual model browser + CLI" },
|
|
1014
|
-
{ value: "ollama", label: "Ollama", hint: "brew install ollama — models download on demand" },
|
|
1015
|
-
{ value: "omlx", label: "oMLX", hint: "brew tap jundot/omlx && brew install omlx — Apple Silicon optimized" },
|
|
1016
|
-
{ value: "all", label: "Install all three", hint: "LM Studio + Ollama + oMLX" },
|
|
1017
|
-
{ value: "skip", label: "Skip for now", hint: "I'll set up models myself" },
|
|
1018
|
-
], "lmstudio");
|
|
1019
|
-
|
|
1020
|
-
const model = recommendedModel();
|
|
1021
|
-
|
|
1022
|
-
if (backendChoice === "lmstudio") {
|
|
1023
|
-
if (!(await ensureHomebrewFor("LM Studio"))) return;
|
|
1024
|
-
console.log(pc.cyan("Installing LM Studio via Homebrew..."));
|
|
1025
|
-
try {
|
|
1026
|
-
await run("brew", ["install", "--cask", "lm-studio"], "LM Studio");
|
|
1027
|
-
console.log(pc.green("✓ LM Studio installed"));
|
|
1028
|
-
console.log(pc.yellow("\nOpen LM Studio and download a model to get started."));
|
|
1029
|
-
console.log(pc.dim(`Recommended for your machine: ${model.label}`));
|
|
1030
|
-
console.log(pc.dim("Then run offgrid-ai again to pick and run a model."));
|
|
1031
|
-
} catch {
|
|
1032
|
-
console.log(pc.red("✗ LM Studio installation failed."));
|
|
1033
|
-
console.log(pc.dim("Download it manually from https://lmstudio.ai"));
|
|
1034
|
-
}
|
|
1035
|
-
} else if (backendChoice === "ollama") {
|
|
1036
|
-
if (!(await ensureHomebrewFor("Ollama"))) return;
|
|
1037
|
-
console.log(pc.cyan("Installing Ollama via Homebrew..."));
|
|
1038
|
-
try {
|
|
1039
|
-
await run("brew", ["install", "ollama"], "Ollama");
|
|
1040
|
-
console.log(pc.green("✓ Ollama installed"));
|
|
1041
|
-
console.log(pc.yellow("\nStart Ollama and pull a model:"));
|
|
1042
|
-
console.log(pc.bold(` ollama serve \u0026 ollama pull ${model.ollama}`));
|
|
1043
|
-
console.log(pc.dim(`Recommended for your machine: ${model.label}`));
|
|
1044
|
-
console.log(pc.dim("Then run offgrid-ai again to pick and run a model."));
|
|
1045
|
-
} catch {
|
|
1046
|
-
console.log(pc.red("✗ Ollama installation failed."));
|
|
1047
|
-
console.log(pc.dim("Install it manually from https://ollama.com"));
|
|
1048
|
-
}
|
|
1049
|
-
} else if (backendChoice === "omlx") {
|
|
1050
|
-
if (!(await ensureHomebrewFor("oMLX"))) return;
|
|
1051
|
-
console.log(pc.cyan("Installing oMLX via Homebrew..."));
|
|
1052
|
-
try {
|
|
1053
|
-
await run("brew", ["tap", "jundot/omlx", "https://github.com/jundot/omlx"], "oMLX tap");
|
|
1054
|
-
await run("brew", ["install", "omlx"], "oMLX");
|
|
1055
|
-
console.log(pc.green("✓ oMLX installed"));
|
|
1056
|
-
console.log(pc.yellow("\nStart oMLX and download a model:"));
|
|
1057
|
-
console.log(pc.bold(" omlx start"));
|
|
1058
|
-
console.log(pc.dim(`Recommended for your machine: ${model.label}`));
|
|
1059
|
-
console.log(pc.dim("Then run offgrid-ai again to pick and run a model."));
|
|
1060
|
-
} catch {
|
|
1061
|
-
console.log(pc.red("✗ oMLX installation failed."));
|
|
1062
|
-
console.log(pc.dim("Install manually: brew tap jundot/omlx && brew install omlx"));
|
|
1063
|
-
}
|
|
1064
|
-
} else if (backendChoice === "all") {
|
|
1065
|
-
if (!(await ensureHomebrewFor("model backends"))) return;
|
|
1066
|
-
let installed = [];
|
|
1067
|
-
// LM Studio
|
|
1068
|
-
console.log(pc.cyan("Installing LM Studio via Homebrew..."));
|
|
1069
|
-
try {
|
|
1070
|
-
await run("brew", ["install", "--cask", "lm-studio"], "LM Studio");
|
|
1071
|
-
installed.push("LM Studio");
|
|
1072
|
-
} catch {
|
|
1073
|
-
console.log(pc.yellow("✗ LM Studio installation failed. Download from https://lmstudio.ai"));
|
|
1074
|
-
}
|
|
1075
|
-
// Ollama
|
|
1076
|
-
console.log(pc.cyan("Installing Ollama via Homebrew..."));
|
|
1077
|
-
try {
|
|
1078
|
-
await run("brew", ["install", "ollama"], "Ollama");
|
|
1079
|
-
installed.push("Ollama");
|
|
1080
|
-
} catch {
|
|
1081
|
-
console.log(pc.yellow("✗ Ollama installation failed. Install manually from https://ollama.com"));
|
|
1082
|
-
}
|
|
1083
|
-
// oMLX
|
|
1084
|
-
console.log(pc.cyan("Installing oMLX via Homebrew..."));
|
|
1085
|
-
try {
|
|
1086
|
-
await run("brew", ["tap", "jundot/omlx", "https://github.com/jundot/omlx"], "oMLX tap");
|
|
1087
|
-
await run("brew", ["install", "omlx"], "oMLX");
|
|
1088
|
-
installed.push("oMLX");
|
|
1089
|
-
} catch {
|
|
1090
|
-
console.log(pc.yellow("✗ oMLX installation failed. Install manually: brew tap jundot/omlx && brew install omlx"));
|
|
1091
|
-
}
|
|
1092
|
-
if (installed.length > 0) {
|
|
1093
|
-
console.log(pc.green(`\n✓ Installed: ${installed.join(", ")}`));
|
|
1094
|
-
console.log(pc.dim(`Recommended for your machine (${(totalmem() / (1024 ** 3)).toFixed(0)}GB RAM): ${model.label}`));
|
|
1095
|
-
}
|
|
1096
|
-
} else {
|
|
1097
|
-
console.log(pc.dim("Run offgrid-ai again when you've set up a model backend."));
|
|
1098
|
-
}
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
console.log(pc.green("\n✓ Setup complete! Run offgrid-ai to pick and run a model."));
|
|
1103
|
-
} finally {
|
|
1104
|
-
prompt.close();
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
// ── Uninstall ───────────────────────────────────────────────────────────────
|
|
1109
|
-
|
|
1110
|
-
async function uninstallCommand(argv) {
|
|
1111
|
-
const { options } = parseOptions(argv);
|
|
1112
|
-
const force = options.force || options.f;
|
|
1113
|
-
|
|
1114
|
-
if (!process.stdin.isTTY && !force) {
|
|
1115
|
-
throw new Error("Non-interactive uninstall requires --force to avoid accidental data loss.");
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
if (force) {
|
|
1119
|
-
await stopTrackedServers();
|
|
1120
|
-
await removeDataDir();
|
|
1121
|
-
await removeShellPath();
|
|
1122
|
-
await removeSelf();
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
startInteractive("offgrid-ai uninstall");
|
|
1127
|
-
const prompt = createPrompt();
|
|
1128
|
-
try {
|
|
1129
|
-
console.log(pc.bold("offgrid-ai uninstall\n"));
|
|
1130
|
-
|
|
1131
|
-
// Stop any running servers first
|
|
1132
|
-
const running = await runningProfiles();
|
|
1133
|
-
if (running.length > 0) {
|
|
1134
|
-
console.log(pc.yellow(`${running.length} server(s) still running. Stopping...`));
|
|
1135
|
-
for (const { profile } of running) {
|
|
1136
|
-
await stopProfile(profile);
|
|
1137
|
-
}
|
|
1138
|
-
console.log(pc.green("All servers stopped."));
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
const dataDir = DATA_DIR;
|
|
1142
|
-
const mode = await prompt.choice("Choose uninstall type", [
|
|
1143
|
-
{ value: "keep-data", label: "Uninstall app only", hint: `keep profiles and settings in ${dataDir}` },
|
|
1144
|
-
{ value: "delete-data", label: "Full uninstall", hint: "delete profiles/settings, then uninstall app" },
|
|
1145
|
-
{ value: "cancel", label: "Cancel" },
|
|
1146
|
-
], "keep-data");
|
|
1147
|
-
|
|
1148
|
-
if (mode === "cancel") {
|
|
1149
|
-
console.log(pc.dim("Cancelled."));
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
if (mode === "delete-data") await removeDataDir();
|
|
1154
|
-
else console.log(pc.dim(`Keeping ${dataDir} for when you reinstall.`));
|
|
1155
|
-
|
|
1156
|
-
await removeShellPath();
|
|
1157
|
-
await removeSelf();
|
|
1158
|
-
} finally {
|
|
1159
|
-
prompt.close();
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
async function stopTrackedServers() {
|
|
1164
|
-
const running = await runningProfiles();
|
|
1165
|
-
for (const { profile } of running) {
|
|
1166
|
-
const result = await stopProfile(profile);
|
|
1167
|
-
console.log(result.stopped ? pc.green(`✓ ${result.message}`) : pc.dim(result.message));
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
async function removeDataDir() {
|
|
1172
|
-
const dataDir = DATA_DIR;
|
|
1173
|
-
if (existsSync(dataDir)) {
|
|
1174
|
-
try {
|
|
1175
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
1176
|
-
console.log(pc.green(`✓ Removed ${dataDir}`));
|
|
1177
|
-
} catch (err) {
|
|
1178
|
-
console.log(pc.red(`Failed to remove ${dataDir}: ${err.message}`));
|
|
1179
|
-
console.log(pc.dim(`Remove it manually: rm -rf ${dataDir}`));
|
|
1180
|
-
}
|
|
1181
|
-
} else {
|
|
1182
|
-
console.log(pc.dim(`${dataDir} doesn't exist — already clean.`));
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
async function removeShellPath() {
|
|
1187
|
-
const cleaned = await removeInstallerPathEntries();
|
|
1188
|
-
if (cleaned.length === 0) {
|
|
1189
|
-
console.log(pc.dim("No offgrid-ai PATH entries found in shell configs."));
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
for (const rcFile of cleaned) {
|
|
1193
|
-
console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
async function removeSelf() {
|
|
1198
|
-
console.log(pc.cyan("\nUninstalling offgrid-ai..."));
|
|
1199
|
-
const { spawn: spawnUninstall } = await import("node:child_process");
|
|
1200
|
-
const verbose = process.argv.includes("--verbose");
|
|
1201
|
-
const runCmd = (cmd, args, label) => new Promise((resolve, reject) => {
|
|
1202
|
-
const stdio = verbose ? "inherit" : ["ignore", "pipe", "pipe"];
|
|
1203
|
-
const child = spawnUninstall(cmd, args, { stdio });
|
|
1204
|
-
if (!verbose) {
|
|
1205
|
-
let stderr = "";
|
|
1206
|
-
child.stderr?.on("data", (d) => { stderr += d; });
|
|
1207
|
-
child.on("close", (code) => {
|
|
1208
|
-
if (code === 0) resolve();
|
|
1209
|
-
else reject(new Error(stderr.split("\n").filter(l => l.trim()).slice(-3).join("\n") || `${label || cmd} exited with code ${code}`));
|
|
1210
|
-
});
|
|
1211
|
-
} else {
|
|
1212
|
-
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${label || cmd} exited with code ${code}`)));
|
|
1213
|
-
}
|
|
1214
|
-
child.on("error", (err) => reject(err));
|
|
1215
|
-
});
|
|
1216
|
-
try {
|
|
1217
|
-
await runCmd("npm", ["uninstall", "-g", "offgrid-ai"], "npm uninstall");
|
|
1218
|
-
console.log(pc.green("\n✓ offgrid-ai has been uninstalled."));
|
|
1219
|
-
console.log(pc.dim("Reinstall anytime with: npm install -g offgrid-ai"));
|
|
1220
|
-
} catch {
|
|
1221
|
-
console.log(pc.red("\n✗ Could not auto-uninstall. Run this manually:"));
|
|
1222
|
-
console.log(pc.bold(" npm uninstall -g offgrid-ai"));
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
// ── Backend install detection (for status display) ────────────────────────
|
|
1227
|
-
|
|
1228
|
-
async function hasOllamaInstalled() {
|
|
1229
|
-
try {
|
|
1230
|
-
const { promisify } = await import("node:util");
|
|
1231
|
-
const { execFile } = await import("node:child_process");
|
|
1232
|
-
await promisify(execFile)("which", ["ollama"]);
|
|
1233
|
-
return true;
|
|
1234
|
-
} catch { return false; }
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
async function hasOmlxInstalled() {
|
|
1238
|
-
try {
|
|
1239
|
-
const { promisify } = await import("node:util");
|
|
1240
|
-
const { execFile } = await import("node:child_process");
|
|
1241
|
-
await promisify(execFile)("which", ["omlx"]);
|
|
1242
|
-
return true;
|
|
1243
|
-
} catch { return false; }
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
1247
|
-
|
|
1248
|
-
async function scanManagedModels() {
|
|
1249
|
-
const results = [];
|
|
1250
|
-
for (const backendId of ["ollama", "omlx"]) {
|
|
1251
|
-
const be = BACKENDS[backendId];
|
|
1252
|
-
try {
|
|
1253
|
-
const models = await be.scanModels();
|
|
1254
|
-
results.push({ backendId, models });
|
|
1255
|
-
} catch { /* backend not running */ }
|
|
1256
|
-
}
|
|
1257
|
-
return results;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
55
|
async function printVersion() {
|
|
1261
56
|
const version = currentPackageVersion();
|
|
1262
57
|
console.log(`offgrid-ai v${version}`);
|
|
@@ -1280,4 +75,4 @@ function printHelp() {
|
|
|
1280
75
|
]), { formatBorder: pc.cyan }));
|
|
1281
76
|
console.log("\n" + renderCard("How it works", "Run offgrid-ai, choose a local model, and start chatting in Pi.\n\nFirst run walks you through missing tools. After that, offgrid-ai remembers your model setup.\n\nFor benchmarks, run offgrid-ai benchmark to prepare a visual or data-science benchmark run.", { formatBorder: pc.magenta }));
|
|
1282
77
|
console.log("\n" + pc.dim("Tip: use --verbose only when you want detailed install output."));
|
|
1283
|
-
}
|
|
78
|
+
}
|