offgrid-ai 0.1.2
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 +107 -0
- package/bin/offgrid-ai.mjs +10 -0
- package/install.sh +162 -0
- package/package.json +51 -0
- package/src/autodetect.mjs +113 -0
- package/src/backends.mjs +135 -0
- package/src/cli.mjs +603 -0
- package/src/config.mjs +102 -0
- package/src/estimate.mjs +113 -0
- package/src/gguf.mjs +70 -0
- package/src/harness-pi.mjs +140 -0
- package/src/json.mjs +16 -0
- package/src/logs.mjs +42 -0
- package/src/process.mjs +175 -0
- package/src/profiles.mjs +165 -0
- package/src/scan.mjs +78 -0
- package/src/ui.mjs +102 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ensureDirs, findLlamaServer, hasHomebrew } from "./config.mjs";
|
|
5
|
+
import { scanGgufModels } from "./scan.mjs";
|
|
6
|
+
import { createProfileFromModel, normalizeProfile } from "./profiles.mjs";
|
|
7
|
+
import { readProfile, saveProfile, deleteProfile, loadProfiles } from "./profiles.mjs";
|
|
8
|
+
import { backendFor, BACKENDS } from "./backends.mjs";
|
|
9
|
+
import { startServer, stopProfile, waitForReady, serverReady, 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, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
|
|
14
|
+
|
|
15
|
+
// ── Entry point ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export async function run(argv) {
|
|
18
|
+
if (argv.length === 0) return mainFlow();
|
|
19
|
+
const [command] = argv;
|
|
20
|
+
|
|
21
|
+
if (command === "help" || command === "--help" || command === "-h") return printHelp();
|
|
22
|
+
if (command === "version" || command === "--version" || command === "-v") return printVersion();
|
|
23
|
+
if (command === "status") return statusCommand();
|
|
24
|
+
if (command === "stop") return stopCommand(argv.slice(1));
|
|
25
|
+
|
|
26
|
+
throw new Error(`Unknown command: ${command}. Run offgrid-ai help`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function mainFlow() {
|
|
30
|
+
await ensureDirs();
|
|
31
|
+
|
|
32
|
+
// 1. Check what backends are available
|
|
33
|
+
const llamaBinary = await findLlamaServer();
|
|
34
|
+
const ggufModels = await scanGgufModels();
|
|
35
|
+
const managedModels = await scanManagedModels();
|
|
36
|
+
const profiles = await loadProfiles();
|
|
37
|
+
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
38
|
+
const hasAnyModels = ggufModels.length > 0 || managedModels.some((m) => m.models.length > 0);
|
|
39
|
+
const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
|
|
40
|
+
|
|
41
|
+
// 2. Nothing available at all — need onboarding
|
|
42
|
+
if (!hasAnyBackend && !hasAnyModels && profiles.length === 0) {
|
|
43
|
+
if (!process.stdin.isTTY) {
|
|
44
|
+
throw new Error("No local LLM backends found. Run offgrid-ai interactively to set up.");
|
|
45
|
+
}
|
|
46
|
+
return await onboardFlow();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 3. Has models but no llama-server (managed backends only)
|
|
50
|
+
if (!llamaBinary && ggufModels.length > 0) {
|
|
51
|
+
// They have GGUF files but can't run them — tell them about llama-server
|
|
52
|
+
console.log(pc.yellow(`${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"} found, but llama-server is not installed.`));
|
|
53
|
+
console.log(pc.dim("Install it with: brew install llama.cpp"));
|
|
54
|
+
console.log(pc.dim("Or use Ollama/oMLX for managed model backends."));
|
|
55
|
+
if (totalManaged === 0 && profiles.length === 0) {
|
|
56
|
+
return; // Nothing to do without llama-server
|
|
57
|
+
}
|
|
58
|
+
// Fall through — they can still use managed backends
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 4. No models found at all (but backends exist)
|
|
62
|
+
if (!hasAnyModels && profiles.length === 0) {
|
|
63
|
+
if (!process.stdin.isTTY) {
|
|
64
|
+
throw new Error("No models found. Download one in LM Studio or start Ollama, then run offgrid-ai.");
|
|
65
|
+
}
|
|
66
|
+
console.log(pc.yellow("No models found."));
|
|
67
|
+
console.log(pc.dim("Download a model in LM Studio (https://lmstudio.ai), start Ollama, or install oMLX."));
|
|
68
|
+
console.log(pc.dim("Then run offgrid-ai again."));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 5. If not interactive, just show status
|
|
73
|
+
if (!process.stdin.isTTY) {
|
|
74
|
+
await statusCommand();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 4. Interactive: pick an action
|
|
79
|
+
startInteractive("offgrid-ai");
|
|
80
|
+
const prompt = createPrompt();
|
|
81
|
+
try {
|
|
82
|
+
// Build items list
|
|
83
|
+
const items = [];
|
|
84
|
+
|
|
85
|
+
// Existing profiles (quick run)
|
|
86
|
+
if (profiles.length > 0) {
|
|
87
|
+
for (const profile of profiles) {
|
|
88
|
+
const running = await isProfileRunning(profile);
|
|
89
|
+
const backend = backendFor(profile.backend);
|
|
90
|
+
items.push({ type: "profile", profile, running });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// New GGUF models (auto-setup)
|
|
95
|
+
const profiledPaths = new Set(profiles.map((p) => p.modelPath).filter(Boolean));
|
|
96
|
+
const newModels = ggufModels.filter((m) => !profiledPaths.has(m.path));
|
|
97
|
+
|
|
98
|
+
// Managed backend models
|
|
99
|
+
const managedItems = [];
|
|
100
|
+
for (const { backendId, models } of managedModels) {
|
|
101
|
+
const profiledAliases = new Set(
|
|
102
|
+
profiles.filter((p) => p.backend === backendId).map((p) => backendId === "ollama" ? `ollama:${p.ollamaModel ?? p.modelAlias}` : `omlx:${p.omlxModel ?? p.modelAlias}`)
|
|
103
|
+
);
|
|
104
|
+
for (const model of models) {
|
|
105
|
+
if (!profiledAliases.has(`${backendId}:${model.id}`)) {
|
|
106
|
+
managedItems.push({ model, backendId });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Show what we found
|
|
112
|
+
if (profiles.length > 0) {
|
|
113
|
+
console.log(pc.bold("\nSaved profiles"));
|
|
114
|
+
for (const profile of profiles) {
|
|
115
|
+
const backend = backendFor(profile.backend);
|
|
116
|
+
const running = await isProfileRunning(profile);
|
|
117
|
+
const idx = items.length;
|
|
118
|
+
const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
|
|
119
|
+
const c = colorMap[profile.backend] ?? pc.magenta;
|
|
120
|
+
console.log(` ${running ? pc.green("●") : pc.dim("○")} ${pc.bold(profile.label)} ${c(`[${backend.label}]`)} · ${pc.cyan(profile.modelAlias)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (newModels.length > 0) {
|
|
124
|
+
console.log(pc.bold("\nNew models"));
|
|
125
|
+
for (const model of newModels.slice(0, 10)) {
|
|
126
|
+
console.log(` ${pc.cyan(model.label)} ${pc.dim(model.quant ?? "")} · ${pc.dim(formatBytes(model.sizeBytes))}`);
|
|
127
|
+
}
|
|
128
|
+
if (newModels.length > 10) console.log(pc.dim(` ... and ${newModels.length - 10} more`));
|
|
129
|
+
}
|
|
130
|
+
for (const { backendId, models } of managedModels) {
|
|
131
|
+
if (models.length > 0) {
|
|
132
|
+
const be = BACKENDS[backendId];
|
|
133
|
+
console.log(pc.bold(`\n${be.label} models`));
|
|
134
|
+
for (const model of models.slice(0, 5)) {
|
|
135
|
+
console.log(` ${pc.cyan(model.label)}`);
|
|
136
|
+
}
|
|
137
|
+
if (models.length > 5) console.log(pc.dim(` ... and ${models.length - 5} more`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Pick what to do
|
|
142
|
+
const action = await prompt.choice("What next?", [
|
|
143
|
+
{ value: "run", label: "Run a model", hint: "Start server and launch Pi" },
|
|
144
|
+
...(profiles.length > 0 ? [{ value: "manage", label: "Manage profiles", hint: "Sync, remove, or inspect" }] : []),
|
|
145
|
+
{ value: "benchmark", label: "Benchmark", hint: "Run a benchmark prompt" },
|
|
146
|
+
], "run");
|
|
147
|
+
|
|
148
|
+
if (action === "run") return await pickAndRun(prompt, profiles, newModels, managedItems);
|
|
149
|
+
if (action === "manage") return await manageProfiles(prompt, profiles);
|
|
150
|
+
if (action === "benchmark") return await benchmarkFlow(prompt, profiles);
|
|
151
|
+
} finally {
|
|
152
|
+
prompt.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Pick and run ────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async function pickAndRun(prompt, profiles, newModels, managedItems) {
|
|
159
|
+
// If there's exactly one profile and it's already running, offer to connect or start fresh
|
|
160
|
+
const choices = [];
|
|
161
|
+
|
|
162
|
+
// Existing profiles
|
|
163
|
+
for (const profile of profiles) {
|
|
164
|
+
const running = await isProfileRunning(profile);
|
|
165
|
+
const backend = backendFor(profile.backend);
|
|
166
|
+
const colorMap = { "llama-cpp": pc.yellow, "llama-cpp-mtp": pc.blue, "ollama": pc.magenta, "omlx": pc.cyan };
|
|
167
|
+
const c = colorMap[profile.backend] ?? pc.magenta;
|
|
168
|
+
choices.push({
|
|
169
|
+
value: `profile:${profile.id}`,
|
|
170
|
+
label: `${running ? pc.green("● ") : ""}${profile.label}`,
|
|
171
|
+
hint: `${c(backend.label)} · ${profile.modelAlias} · ${profile.baseUrl}`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// New GGUF models
|
|
176
|
+
for (const model of newModels.slice(0, 20)) {
|
|
177
|
+
choices.push({
|
|
178
|
+
value: `new:${model.path}`,
|
|
179
|
+
label: model.label,
|
|
180
|
+
hint: `${model.quant ?? "GGUF"} · ${formatBytes(model.sizeBytes)}`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Managed models
|
|
185
|
+
for (const { model, backendId } of managedItems) {
|
|
186
|
+
const be = BACKENDS[backendId];
|
|
187
|
+
choices.push({
|
|
188
|
+
value: `managed:${backendId}:${model.id}`,
|
|
189
|
+
label: model.label,
|
|
190
|
+
hint: `${be.label}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (choices.length === 0) {
|
|
195
|
+
console.log(pc.yellow("No models available."));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const selected = await prompt.choice("Pick a model", choices, choices[0].value);
|
|
200
|
+
|
|
201
|
+
if (selected.startsWith("profile:")) {
|
|
202
|
+
const id = selected.slice("profile:".length);
|
|
203
|
+
const profile = await readProfile(id);
|
|
204
|
+
return await runProfile(profile);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (selected.startsWith("new:")) {
|
|
208
|
+
const modelPath = selected.slice("new:".length);
|
|
209
|
+
const model = newModels.find((m) => m.path === modelPath);
|
|
210
|
+
if (!model) throw new Error("Model not found.");
|
|
211
|
+
const profile = await createProfileFromModel(model);
|
|
212
|
+
await saveProfile(profile);
|
|
213
|
+
console.log(pc.green(`Auto-configured: ${profile.label}`));
|
|
214
|
+
await syncPiConfig(profile);
|
|
215
|
+
return await runProfile(profile);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (selected.startsWith("managed:")) {
|
|
219
|
+
const [, backendId, modelId] = selected.split(":");
|
|
220
|
+
const model = managedItems.find((m) => m.model.id === modelId && m.backendId === backendId)?.model;
|
|
221
|
+
if (!model) throw new Error("Model not found.");
|
|
222
|
+
const profile = normalizeProfile({
|
|
223
|
+
id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
|
|
224
|
+
label: model.label,
|
|
225
|
+
backend: backendId,
|
|
226
|
+
modelAlias: model.aliasSuggestion,
|
|
227
|
+
...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
|
|
228
|
+
...(backendId === "omlx" ? { omlxModel: model.id } : {}),
|
|
229
|
+
});
|
|
230
|
+
await saveProfile(profile);
|
|
231
|
+
await syncPiConfig(profile);
|
|
232
|
+
return await runProfile(profile);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function runProfile(profile, options = {}) {
|
|
237
|
+
const backend = backendFor(profile.backend);
|
|
238
|
+
const withHarness = options.with ?? "pi";
|
|
239
|
+
|
|
240
|
+
// Check harness
|
|
241
|
+
if (withHarness === "pi") {
|
|
242
|
+
const piInstalled = await hasPi();
|
|
243
|
+
if (!piInstalled) {
|
|
244
|
+
console.log(pc.yellow("Pi is not installed. Run with --with server, or install Pi from https://pi.app"));
|
|
245
|
+
console.log(pc.dim("Starting server only..."));
|
|
246
|
+
return await runProfile(profile, { ...options, with: "server" });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const isManaged = backend.type === "managed-server";
|
|
251
|
+
|
|
252
|
+
// Start/verify server
|
|
253
|
+
if (isManaged) {
|
|
254
|
+
if (!(await serverReady(profile.baseUrl))) {
|
|
255
|
+
throw new Error(`${backend.label} is not running at ${profile.baseUrl}. Start it and try again.`);
|
|
256
|
+
}
|
|
257
|
+
console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
|
|
258
|
+
} else {
|
|
259
|
+
const ready = await serverReady(profile.baseUrl);
|
|
260
|
+
if (ready && !options["reuse-existing"]) {
|
|
261
|
+
console.log(pc.green(`[ready] Server already running at ${profile.baseUrl}`));
|
|
262
|
+
console.log(pc.dim("Use --reuse-existing to reuse this server."));
|
|
263
|
+
} else if (!ready) {
|
|
264
|
+
console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
|
|
265
|
+
const state = await startServer(profile);
|
|
266
|
+
const tail = state?.rawLogPath ? tailFriendly(state.rawLogPath, state.friendlyLogPath) : { stop() {} };
|
|
267
|
+
try {
|
|
268
|
+
await waitForReady(profile, state?.pid, state?.rawLogPath);
|
|
269
|
+
console.log(pc.green(`[ready] ${profile.baseUrl}/models`));
|
|
270
|
+
} finally {
|
|
271
|
+
tail.stop();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Show memory estimate for local models
|
|
277
|
+
if (!isManaged && profile.modelPath && existsSync(profile.modelPath)) {
|
|
278
|
+
try {
|
|
279
|
+
const est = estimateMemory(profile.modelPath, profile.mmprojPath, null, profile.flags);
|
|
280
|
+
console.log(renderSection("Memory", renderRows([
|
|
281
|
+
["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
|
|
282
|
+
["Model", formatBytes(est.modelBytes)],
|
|
283
|
+
["KV cache", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"],
|
|
284
|
+
])));
|
|
285
|
+
} catch { /* estimate failed, skip */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Launch harness
|
|
289
|
+
if (withHarness === "pi") {
|
|
290
|
+
if (!(await hasPiModel(profile))) await syncPiConfig(profile);
|
|
291
|
+
try {
|
|
292
|
+
await launchPi(profile);
|
|
293
|
+
} finally {
|
|
294
|
+
if (!isManaged && !options["keep-server"]) {
|
|
295
|
+
const result = await stopProfile(profile);
|
|
296
|
+
console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
if (!isManaged) {
|
|
301
|
+
console.log(pc.dim(`Server running at ${profile.baseUrl}`));
|
|
302
|
+
console.log(pc.dim(`Stop with: offgrid-ai stop ${profile.id}`));
|
|
303
|
+
} else {
|
|
304
|
+
console.log(pc.dim(`${backend.label} is a managed service — offgrid-ai does not stop it.`));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Manage profiles ─────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
async function manageProfiles(prompt, profiles) {
|
|
312
|
+
const choices = profiles.map((p) => ({
|
|
313
|
+
value: p.id,
|
|
314
|
+
label: p.label,
|
|
315
|
+
hint: `${p.modelAlias} · ${p.baseUrl}`,
|
|
316
|
+
}));
|
|
317
|
+
choices.push({ value: "__back", label: "← Back" });
|
|
318
|
+
|
|
319
|
+
const selected = await prompt.choice("Which profile?", choices, choices[0].value);
|
|
320
|
+
if (selected === "__back") return;
|
|
321
|
+
|
|
322
|
+
const profile = await readProfile(selected);
|
|
323
|
+
const backend = backendFor(profile.backend);
|
|
324
|
+
const isManaged = backend.type === "managed-server";
|
|
325
|
+
|
|
326
|
+
// Show profile details
|
|
327
|
+
console.log("");
|
|
328
|
+
console.log(renderSection("Profile", renderRows([
|
|
329
|
+
["ID", pc.cyan(profile.id)],
|
|
330
|
+
["Label", pc.bold(profile.label)],
|
|
331
|
+
["Backend", backend.label],
|
|
332
|
+
["Endpoint", pc.green(profile.baseUrl)],
|
|
333
|
+
...(!isManaged ? [
|
|
334
|
+
["Model", profile.modelPath ?? "unknown"],
|
|
335
|
+
["MMProj", profile.mmprojPath ?? "none"],
|
|
336
|
+
["Memory", existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
|
|
337
|
+
] : []),
|
|
338
|
+
["Alias", pc.cyan(profile.modelAlias)],
|
|
339
|
+
["Pi", (await hasPiModel(profile)) ? pc.green("configured") : pc.yellow("not synced")],
|
|
340
|
+
])));
|
|
341
|
+
|
|
342
|
+
if (!isManaged && profile.commandArgv) {
|
|
343
|
+
console.log("");
|
|
344
|
+
console.log(pc.bold("Auto-detected flags"));
|
|
345
|
+
console.log(pc.dim(profile.commandArgv.join(" ")));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const action = await prompt.choice("Action", [
|
|
349
|
+
{ value: "sync", label: "Sync Pi config", hint: "Update ~/.pi/agent/models.json" },
|
|
350
|
+
{ value: "run", label: "Run", hint: "Start server + Pi" },
|
|
351
|
+
...(isManaged ? [] : [{ value: "server", label: "Server only", hint: "Start server, no harness" }]),
|
|
352
|
+
{ value: "remove", label: "Remove", hint: "Delete profile + Pi config" },
|
|
353
|
+
{ value: "__back", label: "← Back" },
|
|
354
|
+
], "sync");
|
|
355
|
+
|
|
356
|
+
if (action === "sync") {
|
|
357
|
+
await syncPiConfig(profile);
|
|
358
|
+
} else if (action === "run") {
|
|
359
|
+
return await runProfile(profile);
|
|
360
|
+
} else if (action === "server") {
|
|
361
|
+
return await runProfile(profile, { with: "server" });
|
|
362
|
+
} else if (action === "remove") {
|
|
363
|
+
await removeProfileInteractive(profile.id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function removeProfileInteractive(id) {
|
|
368
|
+
const profile = await readProfile(id);
|
|
369
|
+
if (!process.stdin.isTTY) {
|
|
370
|
+
console.log(pc.red(`Use --force to remove ${id} non-interactively.`));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const prompt = createPrompt();
|
|
374
|
+
try {
|
|
375
|
+
const confirmed = await prompt.yesNo(`Remove ${profile.label} (${profile.id})?`, false);
|
|
376
|
+
if (!confirmed) { console.log(pc.dim("Cancelled.")); return; }
|
|
377
|
+
} finally {
|
|
378
|
+
prompt.close();
|
|
379
|
+
}
|
|
380
|
+
if (await isProfileRunning(profile)) {
|
|
381
|
+
console.log(pc.yellow("Stopping running server..."));
|
|
382
|
+
await stopProfile(profile);
|
|
383
|
+
}
|
|
384
|
+
await removeFromPiConfig(profile);
|
|
385
|
+
await deleteProfile(id);
|
|
386
|
+
console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Benchmark (stub) ────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
async function benchmarkFlow(prompt, profiles) {
|
|
392
|
+
console.log(pc.yellow("Benchmark support coming soon."));
|
|
393
|
+
console.log(pc.dim("This will require the local-llm-visual-benchmark repo."));
|
|
394
|
+
console.log(pc.dim("For now, start a model with offgrid-ai and run benchmarks manually."));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Status ──────────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
async function statusCommand() {
|
|
400
|
+
await ensureDirs();
|
|
401
|
+
const profiles = await loadProfiles();
|
|
402
|
+
|
|
403
|
+
// Check all profiles for running status
|
|
404
|
+
const statuses = [];
|
|
405
|
+
for (const profile of profiles) {
|
|
406
|
+
const status = await profileRuntimeStatus(profile);
|
|
407
|
+
statuses.push({ profile, status });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const running = statuses.filter((s) => s.status.running);
|
|
411
|
+
|
|
412
|
+
if (running.length === 0) {
|
|
413
|
+
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
414
|
+
if (profiles.length > 0) {
|
|
415
|
+
console.log(pc.dim(`\n${profiles.length} profile(s) available. Run offgrid-ai to start one.`));
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
console.log(pc.bold(`${running.length} server${running.length === 1 ? "" : "s"} running`));
|
|
421
|
+
for (const { profile, status } of running) {
|
|
422
|
+
const backend = backendFor(profile.backend);
|
|
423
|
+
console.log(` ${pc.green("●")} ${pc.bold(profile.label)} ${pc.dim(`[${backend.label}]`)}`);
|
|
424
|
+
console.log(` id: ${pc.cyan(profile.id)} · pid: ${status.pid} · ${status.ready ? pc.green("ready") : pc.yellow("loading")}`);
|
|
425
|
+
console.log(` ${profile.baseUrl}`);
|
|
426
|
+
}
|
|
427
|
+
console.log(pc.dim("\nStop with: offgrid-ai stop"));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Stop ────────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
async function stopCommand(argv) {
|
|
433
|
+
await ensureDirs();
|
|
434
|
+
const { positional, options } = parseOptions(argv);
|
|
435
|
+
|
|
436
|
+
if (options.all) return stopAll();
|
|
437
|
+
if (positional[0]) return stopOne(positional[0]);
|
|
438
|
+
|
|
439
|
+
// Interactive
|
|
440
|
+
const running = await runningProfiles();
|
|
441
|
+
if (running.length === 0) {
|
|
442
|
+
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!process.stdin.isTTY) {
|
|
447
|
+
for (const { profile, status } of running) {
|
|
448
|
+
console.log(` ${pc.green("●")} ${pc.bold(profile.label)} · pid ${status.pid}`);
|
|
449
|
+
}
|
|
450
|
+
console.log(pc.dim("Stop with: offgrid-ai stop <id>"));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
startInteractive("offgrid-ai stop");
|
|
455
|
+
const prompt = createPrompt();
|
|
456
|
+
try {
|
|
457
|
+
const choices = running.map(({ profile, status }) => ({
|
|
458
|
+
value: profile.id, label: profile.label, hint: `pid ${status.pid} · ${profile.baseUrl}`,
|
|
459
|
+
}));
|
|
460
|
+
if (running.length > 1) choices.unshift({ value: "__all", label: "Stop all", hint: `${running.length} servers` });
|
|
461
|
+
choices.push({ value: "__cancel", label: "Cancel" });
|
|
462
|
+
|
|
463
|
+
const selected = await prompt.choice("Stop", choices, choices[0].value);
|
|
464
|
+
if (selected === "__cancel") return;
|
|
465
|
+
|
|
466
|
+
const targets = selected === "__all" ? running : running.filter((i) => i.profile.id === selected);
|
|
467
|
+
for (const { profile } of targets) {
|
|
468
|
+
const result = await stopProfile(profile);
|
|
469
|
+
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
prompt.close();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function stopOne(id) {
|
|
477
|
+
const profile = await readProfile(id);
|
|
478
|
+
const result = await stopProfile(profile);
|
|
479
|
+
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function stopAll() {
|
|
483
|
+
const running = await runningProfiles();
|
|
484
|
+
if (running.length === 0) {
|
|
485
|
+
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
for (const { profile } of running) {
|
|
489
|
+
const result = await stopProfile(profile);
|
|
490
|
+
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function runningProfiles() {
|
|
495
|
+
const profiles = await loadProfiles();
|
|
496
|
+
const statuses = await Promise.all(profiles.map(async (profile) => ({ profile, status: await profileRuntimeStatus(profile) })));
|
|
497
|
+
return statuses.filter((i) => i.status.running);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Onboarding ──────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
async function onboardFlow() {
|
|
503
|
+
startInteractive("offgrid-ai setup");
|
|
504
|
+
const prompt = createPrompt();
|
|
505
|
+
try {
|
|
506
|
+
console.log(pc.bold("Welcome to offgrid-ai!"));
|
|
507
|
+
console.log(pc.dim("Let's make sure you have everything you need to run local models.\n"));
|
|
508
|
+
|
|
509
|
+
// 1. Homebrew
|
|
510
|
+
const hasBrew = await hasHomebrew();
|
|
511
|
+
if (!hasBrew) {
|
|
512
|
+
const install = await prompt.yesNo("Homebrew is required. Install it?", true);
|
|
513
|
+
if (!install) { console.log(pc.red("offgrid-ai needs Homebrew. Install it from https://brew.sh")); return; }
|
|
514
|
+
console.log(pc.dim("Install Homebrew from https://brew.sh, then run offgrid-ai again."));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
console.log(pc.green("✓ Homebrew found"));
|
|
518
|
+
|
|
519
|
+
// 2. llama-server
|
|
520
|
+
let llamaBinary = await findLlamaServer();
|
|
521
|
+
if (!llamaBinary) {
|
|
522
|
+
const install = await prompt.yesNo("llama-server is required. Install via Homebrew?", true);
|
|
523
|
+
if (!install) { console.log(pc.red("offgrid-ai needs llama-server to run local models.")); return; }
|
|
524
|
+
console.log(pc.cyan("Installing llama.cpp..."));
|
|
525
|
+
const { execFile } = await import("node:child_process");
|
|
526
|
+
const { promisify } = await import("node:util");
|
|
527
|
+
try {
|
|
528
|
+
await promisify(execFile)("brew", ["install", "llama.cpp"], { stdio: "inherit" });
|
|
529
|
+
llamaBinary = await findLlamaServer();
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.log(pc.red(`Failed: ${err.message}`));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
|
|
536
|
+
|
|
537
|
+
// 3. Check for models
|
|
538
|
+
const ggufModels = await scanGgufModels();
|
|
539
|
+
const managedModels = await scanManagedModels();
|
|
540
|
+
const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
|
|
541
|
+
|
|
542
|
+
if (ggufModels.length > 0) {
|
|
543
|
+
console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"} in LM Studio`));
|
|
544
|
+
}
|
|
545
|
+
for (const { backendId, models } of managedModels) {
|
|
546
|
+
if (models.length > 0) {
|
|
547
|
+
console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (ggufModels.length === 0 && totalManaged === 0) {
|
|
552
|
+
console.log(pc.yellow("\nNo models found. Download one in LM Studio, start Ollama, or install oMLX."));
|
|
553
|
+
console.log(pc.dim(" LM Studio: https://lmstudio.ai"));
|
|
554
|
+
console.log(pc.dim(" Then run offgrid-ai again."));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
console.log(pc.green("\nSetup complete! Run offgrid-ai to pick and run a model."));
|
|
559
|
+
} finally {
|
|
560
|
+
prompt.close();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
async function scanManagedModels() {
|
|
567
|
+
const results = [];
|
|
568
|
+
for (const backendId of ["ollama", "omlx"]) {
|
|
569
|
+
const be = BACKENDS[backendId];
|
|
570
|
+
try {
|
|
571
|
+
const models = await be.scanModels();
|
|
572
|
+
results.push({ backendId, models });
|
|
573
|
+
} catch { /* backend not running */ }
|
|
574
|
+
}
|
|
575
|
+
return results;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function printVersion() {
|
|
579
|
+
const { readFileSync } = await import("node:fs");
|
|
580
|
+
const { dirname, join } = await import("node:path");
|
|
581
|
+
const { fileURLToPath } = await import("node:url");
|
|
582
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
583
|
+
try {
|
|
584
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
585
|
+
console.log(`offgrid-ai v${pkg.version}`);
|
|
586
|
+
} catch {
|
|
587
|
+
console.log("offgrid-ai v0.1.0");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function printHelp() {
|
|
592
|
+
console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner
|
|
593
|
+
|
|
594
|
+
Usage:
|
|
595
|
+
offgrid-ai Pick a model and run it
|
|
596
|
+
offgrid-ai status Show running local models
|
|
597
|
+
offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
|
|
598
|
+
offgrid-ai help Show this help
|
|
599
|
+
offgrid-ai version Show version
|
|
600
|
+
|
|
601
|
+
First run? offgrid-ai walks you through installing everything you need.
|
|
602
|
+
After that, just run it — it finds your models, auto-configures, and launches Pi.`);
|
|
603
|
+
}
|