offgrid-ai 0.16.0 → 0.17.0

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # offgrid-ai
4
4
 
5
- **Helper CLI for running local AI models on Mac with llama-server, mlx-vlm, and oMLX.**
5
+ **Helper CLI for running local AI models on Mac with llama-server and oMLX.**
6
6
 
7
7
  [![node](https://img.shields.io/badge/node-20%2B-3c873a)](package.json)
8
8
  [![platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue)]()
@@ -12,7 +12,7 @@
12
12
 
13
13
  ## What is offgrid-ai?
14
14
 
15
- offgrid-ai is a command-line tool that lets you run AI models locally. Running local models with llama-server, mlx-vlm, or oMLX have a steep learning curve compared to cloud-based models, so offgrid-ai is designed to abstract away the complexity, while still providing a powerful and flexible way to run local models.
15
+ offgrid-ai is a command-line tool that lets you run AI models locally. Running local models with llama-server or oMLX have a steep learning curve compared to cloud-based models, so offgrid-ai is designed to abstract away the complexity, while still providing a powerful and flexible way to run local models.
16
16
 
17
17
  This is the recommended workflow:
18
18
 
@@ -23,8 +23,8 @@ This is the recommended workflow:
23
23
  ## Core Features
24
24
  - Auto-detects available models from LM Studio, oMLX, and HuggingFace
25
25
  - Auto-detects MTP (multi-token prediction) or QAT (quantization aware training) models, and applies the correct flags for llama.cpp
26
- - Auto-applies the optimal flags for the model type (llama.cpp server flags, mlx-vlm APC/thinking/context flags)
27
- - Start / stop local servers automatically for chat sessions (llama-server and mlx-vlm)
26
+ - Auto-applies the optimal flags for the model type (llama.cpp server flags, oMLX auto-start and cache management)
27
+ - Start / stop local servers automatically for chat sessions (llama-server and oMLX)
28
28
 
29
29
  ## Quick start
30
30
 
@@ -77,14 +77,12 @@ Pick a model from the list and press Enter. offgrid-ai configures the rest and o
77
77
  offgrid-ai # primary entry-point for the CLI
78
78
  offgrid-ai status # see if any model is running
79
79
  offgrid-ai stop # stop the running model
80
- offgrid-ai benchmark # run a benchmark paired with my local llm benchmark runner
81
80
  offgrid-ai uninstall # remove offgrid-ai
82
81
  ```
83
82
 
84
83
  ## What can I do with it?
85
84
 
86
85
  - **Chat with local models** — you download the models yourself, and then offgrid-ai helps configure and run then
87
- - **Run benchmarks** — compare how different models perform on creative or data-science tasks. Pairs with my other [local llm benchmark runner](https://github.com/eeshansrivastava89/local-llm-visual-benchmark)
88
86
  - **Keep data private** — everything runs on your machine without any cloud connections
89
87
 
90
88
  ## Need help?
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.16.0",
4
- "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
3
+ "version": "0.17.0",
4
+ "description": "Privacy-first CLI for running local LLMs — discover, configure, run, and chat",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
7
7
  "bin": {
@@ -11,7 +11,6 @@
11
11
  "bin/*.mjs",
12
12
  "src/*.mjs",
13
13
  "src/commands/*.mjs",
14
- "src/benchmark/*.mjs",
15
14
  "resources/*.py",
16
15
  "resources/recommendations.json",
17
16
  "install.sh"
@@ -34,7 +33,7 @@
34
33
  "start": "node bin/offgrid-ai.mjs",
35
34
  "test": "node --test test/*.mjs",
36
35
  "test:integration": "OFFGRID_INTEGRATION=1 node --test test/integration/*.mjs",
37
- "lint": "eslint src/*.mjs src/commands/*.mjs src/benchmark/*.mjs scripts/*.mjs bin/*.mjs",
36
+ "lint": "eslint src/*.mjs src/commands/*.mjs scripts/*.mjs bin/*.mjs",
38
37
  "check:privacy": "node scripts/privacy-gate.mjs",
39
38
  "release:check": "bash scripts/release-check.sh",
40
39
  "release:check:fast": "bash scripts/release-check.sh --skip-install --skip-manual",
@@ -42,9 +41,6 @@
42
41
  "pretest": "npm run lint"
43
42
  },
44
43
  "dependencies": {
45
- "@earendil-works/pi-agent-core": "^0.80.3",
46
- "@earendil-works/pi-ai": "^0.80.3",
47
- "@earendil-works/pi-coding-agent": "^0.80.3",
48
44
  "@inquirer/prompts": "^8.5.2",
49
45
  "picocolors": "^1.1.0"
50
46
  },
@@ -61,9 +57,5 @@
61
57
  "@eslint/js": "^10.0.1",
62
58
  "eslint": "^10.4.1",
63
59
  "globals": "^17.6.0"
64
- },
65
- "allowScripts": {
66
- "@google/genai": true,
67
- "protobufjs": true
68
60
  }
69
61
  }
@@ -55,7 +55,7 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
55
55
  const isLowMem = quant && /[Qq]4[_0]/i.test(quant);
56
56
 
57
57
  const flags = {
58
- ...defaultFlagsForBackend(mtp ? "llama-cpp-mtp" : "llama-cpp"),
58
+ ...defaultFlagsForBackend("llama-cpp"),
59
59
  ctxSize: capabilities.ctxSize,
60
60
  flashAttention: "on",
61
61
  cacheTypeK: isLowMem ? "f16" : "bf16",
package/src/backends.mjs CHANGED
@@ -7,7 +7,6 @@ import { scanOmlxModelSizes, lookupOmlxModelInfo } from "./mlx-discovery.mjs";
7
7
 
8
8
  export const LOCAL_HOST = "127.0.0.1";
9
9
  export const LLAMA_CPP_PORT = 8080;
10
- export const LLAMA_CPP_MTP_PORT = 8081;
11
10
  export const OMLX_PORT = 8000;
12
11
 
13
12
  export function baseUrlFor({ host = LOCAL_HOST, port, path = "/v1" }) {
@@ -30,17 +29,6 @@ export const BACKENDS = {
30
29
  needsCommandFile: true,
31
30
  scanModels: async () => (await scanGgufModels()).models,
32
31
  },
33
- "llama-cpp-mtp": {
34
- id: "llama-cpp-mtp",
35
- label: "llama.cpp MTP",
36
- type: "local-server",
37
- providerId: "llama-cpp-mtp",
38
- defaultHost: LOCAL_HOST,
39
- defaultPort: LLAMA_CPP_MTP_PORT,
40
- defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_MTP_PORT }),
41
- needsCommandFile: true,
42
- scanModels: async () => (await scanGgufModels()).models,
43
- },
44
32
  "omlx": {
45
33
  id: "omlx",
46
34
  label: "oMLX",
package/src/cli.mjs CHANGED
@@ -5,7 +5,6 @@ import { modelsCommand } from "./commands/models.mjs";
5
5
  import { runCommand } from "./commands/run.mjs";
6
6
  import { statusCommand } from "./commands/status.mjs";
7
7
  import { stopCommand } from "./commands/stop.mjs";
8
- import { benchmarkCommand } from "./commands/benchmark.mjs";
9
8
  import { uninstallCommand } from "./commands/uninstall.mjs";
10
9
 
11
10
  async function offerUpdate(argv) {
@@ -45,7 +44,6 @@ export async function run(argv) {
45
44
  if (command === "run") return runCommand(argv.slice(1));
46
45
  if (command === "status") return statusCommand();
47
46
  if (command === "stop") return stopCommand(argv.slice(1));
48
- if (command === "benchmark") return benchmarkCommand();
49
47
  if (command === "uninstall" || command === "--uninstall") return uninstallCommand(argv.slice(1));
50
48
  if (command === "--verbose") return mainFlow();
51
49
 
@@ -69,10 +67,9 @@ function printHelp() {
69
67
  ["Start", pc.bold("offgrid-ai")],
70
68
  ["Status", "offgrid-ai status"],
71
69
  ["Stop", "offgrid-ai stop"],
72
- ["Benchmark", "offgrid-ai benchmark"],
73
70
  ["Uninstall", "offgrid-ai uninstall"],
74
71
  ["Version", "offgrid-ai version"],
75
72
  ]), { formatBorder: pc.cyan }));
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 }));
73
+ 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.", { formatBorder: pc.magenta }));
77
74
  console.log("\n" + pc.dim("Tip: use --verbose only when you want detailed install output."));
78
75
  }
@@ -4,9 +4,10 @@ import { scanGgufModels } from "../scan.mjs";
4
4
  import { loadProfiles } from "../profiles.mjs";
5
5
  import { hasPi } from "../harness-pi.mjs";
6
6
  import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
7
- import { hasLmStudioInstalled, hasOmlxInstalled, scanManagedModels } from "../managed.mjs";
7
+ import { offerManagedOmlxUpdate, hasOmlx } from "../omlx-runtime.mjs";
8
+ import { hasLmStudioInstalled, scanManagedModels } from "../managed.mjs";
8
9
  import { recommendedModel } from "../recommendations.mjs";
9
- import { pc, startInteractive, createPrompt } from "../ui.mjs";
10
+ import { pc, startInteractive, createPrompt, renderCard } from "../ui.mjs";
10
11
  import { onboardFlow } from "./onboard.mjs";
11
12
  import { modelCommandCenter } from "./models.mjs";
12
13
  import { statusCommand } from "./status.mjs";
@@ -18,6 +19,7 @@ export async function mainFlow() {
18
19
  const runtimePrompt = createPrompt();
19
20
  try {
20
21
  await offerManagedLlamaRuntimeUpdate(runtimePrompt);
22
+ await offerManagedOmlxUpdate(runtimePrompt);
21
23
  } finally {
22
24
  runtimePrompt.close();
23
25
  }
@@ -56,14 +58,36 @@ export async function mainFlow() {
56
58
  if (!process.stdin.isTTY) return await statusCommand();
57
59
 
58
60
  startInteractive("offgrid-ai");
61
+ printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled: await hasOmlx(), profiles });
62
+ console.log(pc.dim(" How to get models — offgrid-ai finds them on disk after you download:"));
63
+ console.log(pc.dim(" LM Studio Open LM Studio app, browse and download"));
64
+ console.log(pc.dim(" oMLX Open oMLX app, browse and download"));
65
+ console.log(pc.dim(" HuggingFace hf download mlx-community/gemma-4-e2b-it-4bit"));
66
+ console.log("");
59
67
  return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
60
68
  }
61
69
 
70
+ function printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled, profiles }) {
71
+ const omlxServerUp = managedModels.some((m) => m.backendId === "omlx" && m.status === "ok");
72
+ const parts = [
73
+ llamaBinary ? pc.green("llama.cpp ✓") : pc.red("llama.cpp ✗"),
74
+ ];
75
+ if (omlxInstalled) {
76
+ parts.push(omlxServerUp ? pc.green("oMLX ✓ server up") : pc.yellow("oMLX ✓ server down"));
77
+ } else {
78
+ parts.push(pc.red("oMLX ✗"));
79
+ }
80
+ parts.push(piInstalled ? pc.green("Pi ✓") : pc.red("Pi ✗"));
81
+ if (profiles.length > 0) parts.push(pc.dim(`${profiles.length} model${profiles.length === 1 ? "" : "s"}`));
82
+ console.log(renderCard("offgrid-ai", parts.join(pc.dim(" · ")), { formatBorder: pc.cyan }));
83
+ console.log("");
84
+ }
85
+
62
86
  async function printNoModelsHelp(llamaBinary) {
63
87
  console.log(pc.yellow("No models found."));
64
88
  console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
65
89
 
66
- const omlxInstalled = await hasOmlxInstalled();
90
+ const omlxInstalled = await hasOmlx();
67
91
  const lmStudioInstalled = hasLmStudioInstalled();
68
92
  const hasBackends = llamaBinary || omlxInstalled || lmStudioInstalled;
69
93
  if (!hasBackends) {
@@ -1,12 +1,16 @@
1
- import { ensureDirs } from "../config.mjs";
1
+ import { ensureDirs, getModelScanDirs, addModelScanDir, removeModelScanDir, DEFAULT_MODEL_DIRS, findLlamaServer, HF_HUB_DIR } from "../config.mjs";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { backendFor, BACKENDS } from "../backends.mjs";
3
6
  import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
4
7
  import { isProfileRunning, isProfileServerUp, modelAvailableOnServer, stopProfile } from "../process.mjs";
5
- import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
6
- import { configureLocalProfile } from "../profile-setup.mjs";
7
- import { pc, startInteractive, createPrompt, modelSelect } from "../ui.mjs";
8
+ import { syncPiConfig, removeFromPiConfig, hasPi } from "../harness-pi.mjs";
9
+ import { hasOmlx } from "../omlx-runtime.mjs";
10
+ import { configureLocalProfile, configureManagedProfile } from "../profile-setup.mjs";
11
+ import { pc, startInteractive, createPrompt, modelSelect, renderCard, renderRows } from "../ui.mjs";
8
12
  import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
9
- import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails, printWorkspaceHeader, printBenchmarkLine, printProfileDetails } from "../model-presenters.mjs";
13
+ import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails, printProfileDetails } from "../model-presenters.mjs";
10
14
  import { runProfile } from "./run.mjs";
11
15
 
12
16
  const { stripVTControlCharacters } = await import("node:util");
@@ -31,7 +35,19 @@ export async function modelCommandCenter(initialCatalog) {
31
35
  return;
32
36
  }
33
37
 
34
- const catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
38
+ let catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
39
+
40
+ while (true) {
41
+ const result = await showModelPicker(catalog);
42
+ if (result === "rescan") {
43
+ catalog = await loadModelCatalog();
44
+ continue;
45
+ }
46
+ return;
47
+ }
48
+ }
49
+
50
+ async function showModelPicker(catalog) {
35
51
  const normalized = normalizeCatalog(catalog);
36
52
  const allItems = buildCatalogItems(normalized);
37
53
  if (allItems.length === 0) {
@@ -50,9 +66,6 @@ export async function modelCommandCenter(initialCatalog) {
50
66
  if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
51
67
  }
52
68
  }
53
- printWorkspaceHeader(normalized, runningProfilesNow, modelMissingIds);
54
- await printBenchmarkLine();
55
-
56
69
  const nameWidth = modelNameWidth(allItems);
57
70
 
58
71
  const statusFor = (item) => {
@@ -82,16 +95,10 @@ export async function modelCommandCenter(initialCatalog) {
82
95
  }
83
96
 
84
97
  const groups = [];
85
- const backendColors = {
86
- "llama-cpp": pc.cyan,
87
- "llama-cpp-mtp": pc.blue,
88
- omlx: pc.magenta,
89
- };
90
98
  for (const { backendId, sourceId, items } of byBackend.values()) {
91
99
  const backendLabel = backendFor(backendId)?.label ?? backendId;
92
100
  const sourceLabel = formatSourceLabel(sourceId);
93
- const color = backendColors[backendId] ?? pc.dim;
94
- const sep = `Inference: ${pc.bold(color(backendLabel))} ${pc.dim("|")} Source: ${sourceLabel} (${items.length})`;
101
+ const sep = ` ${pc.dim(backendLabel + " · " + sourceLabel + " (" + items.length + ")")}`;
95
102
  const groupItems = items.map((item) => {
96
103
  const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, compact: true });
97
104
  return { value: opt.value, label: opt.label, description: opt.description };
@@ -104,13 +111,21 @@ export async function modelCommandCenter(initialCatalog) {
104
111
  const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, compact: true });
105
112
  return { value: opt.value, label: opt.label, description: opt.description };
106
113
  });
107
- groups.push({ separator: ` ${pc.bold(pc.yellow(`Needs setup (${setupItems.length})`))}`, items: groupItems });
114
+ groups.push({ separator: ` ${pc.yellow("Needs setup (" + setupItems.length + ")")}`, items: groupItems });
108
115
  }
109
116
 
117
+ groups.push({ separator: " ", items: [{ value: "__settings__", label: `${pc.dim("○")} ${pc.cyan("⚙ Status & settings")}` }] });
118
+
110
119
  const prompt = createPrompt();
111
120
  try {
112
121
  const selected = await modelSelect("Select a model", groups, { pageSize: 20 });
113
122
  if (!selected) return;
123
+
124
+ if (selected === "__settings__") {
125
+ await settingsFlow(prompt);
126
+ return "rescan";
127
+ }
128
+
114
129
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
115
130
  if (!item) return;
116
131
 
@@ -145,10 +160,6 @@ function actionsForItem(item) {
145
160
  { value: "run", name: "Start chatting", desc: "Launch and open Pi" },
146
161
  { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings" },
147
162
  );
148
- const backend = backendFor(item.profile.backend);
149
- if (backend.type === "local-server" || backend.type === "managed-server") {
150
- available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run" });
151
- }
152
163
  }
153
164
  available.push({ value: "remove", name: "Remove", desc: missing ? "Delete this broken setup" : "Delete this setup" });
154
165
  if (missing) {
@@ -156,10 +167,6 @@ function actionsForItem(item) {
156
167
  { value: "run", name: "Start chatting", desc: "Launch and open Pi", dimmed: true },
157
168
  { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings", dimmed: true },
158
169
  );
159
- const backend = backendFor(item.profile.backend);
160
- if (backend.type === "local-server" || backend.type === "managed-server") {
161
- available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run", dimmed: true });
162
- }
163
170
  }
164
171
  return formatActions(available);
165
172
  }
@@ -177,7 +184,7 @@ function actionsForItem(item) {
177
184
 
178
185
  async function performAction(prompt, action, item) {
179
186
  const missing = item.type === "profile" && item.fileMissing;
180
- if (missing && ["run", "reconfigure", "benchmark"].includes(action)) {
187
+ if (missing && ["run", "reconfigure"].includes(action)) {
181
188
  console.log(pc.red("This model's file is no longer on disk. Remove the setup or move the file back."));
182
189
  return;
183
190
  }
@@ -186,16 +193,8 @@ async function performAction(prompt, action, item) {
186
193
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
187
194
  return printGgufModelDetails(item.model, item.drafter);
188
195
  }
189
- if (action === "benchmark") {
190
- if (item.type === "profile") {
191
- const { benchmarkForProfile } = await import("../benchmark.mjs");
192
- return await benchmarkForProfile(await readProfile(item.profile.id));
193
- }
194
- const { benchmarkFlow } = await import("../benchmark.mjs");
195
- return await benchmarkFlow();
196
- }
197
196
  if (action === "run") return await runItem(item);
198
- if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item, action);
197
+ if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item);
199
198
  if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
200
199
  }
201
200
 
@@ -207,26 +206,28 @@ function printProfileSaved(id) {
207
206
  console.log(pc.dim(` Profile: ${profileJsonPath(id)}`));
208
207
  }
209
208
 
210
- async function setupItem(prompt, item, action) {
209
+ async function setupItem(prompt, item) {
211
210
  if (item.type === "profile") {
212
211
  const configured = await configureLocalProfile(prompt, await readProfile(item.profile.id));
213
212
  if (!configured) return;
214
- await saveProfile(configured, { writeCommand: true });
213
+ await saveProfile(configured);
215
214
  await syncPiConfig(configured);
216
215
  printProfileSaved(configured.id);
217
216
  return;
218
217
  }
219
218
  if (item.type === "managed") {
220
219
  const profile = createManagedProfile(item.model, item.backendId);
221
- await saveProfile(profile);
222
- await syncPiConfig(profile);
223
- printProfileSaved(profile.id);
220
+ const configured = await configureManagedProfile(prompt, profile);
221
+ if (!configured) return;
222
+ await saveProfile(configured);
223
+ await syncPiConfig(configured);
224
+ printProfileSaved(configured.id);
224
225
  return;
225
226
  }
226
227
  const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
227
228
  const configured = await configureLocalProfile(prompt, profile);
228
229
  if (!configured) return;
229
- await saveProfile(configured, { writeCommand: action === "reconfigure" });
230
+ await saveProfile(configured);
230
231
  await syncPiConfig(configured);
231
232
  printProfileSaved(configured.id);
232
233
  }
@@ -254,4 +255,77 @@ async function removeProfileInteractive(id) {
254
255
  await removeFromPiConfig(profile);
255
256
  await deleteProfile(id);
256
257
  console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
258
+ }
259
+
260
+ // ── Settings & discovery path management ───────────────────────────────────
261
+
262
+ async function settingsFlow(prompt) {
263
+ while (true) {
264
+ const llamaBinary = await findLlamaServer();
265
+ const omlxInstalled = await hasOmlx();
266
+ const piInstalled = await hasPi();
267
+
268
+ let omlxServerUp = false;
269
+ if (omlxInstalled) {
270
+ try {
271
+ const res = await fetch("http://127.0.0.1:8000/v1/models", { signal: AbortSignal.timeout(2000) });
272
+ omlxServerUp = res.ok;
273
+ } catch { /* server down */ }
274
+ }
275
+
276
+ console.log("");
277
+ console.log(renderCard("Runtime status", renderRows([
278
+ ["llama.cpp", llamaBinary ? pc.green("✓ ") + pc.dim(llamaBinary) : pc.red("✗ not found")],
279
+ ["oMLX", omlxInstalled ? (omlxServerUp ? pc.green("✓ server up") : pc.yellow("✓ installed · server down")) : pc.red("✗ not found")],
280
+ ["Pi", piInstalled ? pc.green("✓ installed") : pc.red("✗ not found")],
281
+ ]), { formatBorder: pc.cyan }));
282
+
283
+ const scanDirs = await getModelScanDirs();
284
+ const defaultSet = new Set(DEFAULT_MODEL_DIRS);
285
+ const pathLabels = new Map([
286
+ [join(homedir(), ".lmstudio", "models"), "LM Studio downloads"],
287
+ [join(homedir(), ".omlx", "models"), "oMLX downloads"],
288
+ [HF_HUB_DIR, "HuggingFace CLI downloads"],
289
+ ]);
290
+ const pathRows = scanDirs.map((dir) => {
291
+ const exists = existsSync(dir);
292
+ const isBuiltin = defaultSet.has(dir);
293
+ const desc = pathLabels.get(dir);
294
+ const label = `${exists ? pc.green("✓") : pc.red("✗")} ${dir}`;
295
+ const tags = [desc, isBuiltin ? "built-in" : "custom"].filter(Boolean).join(pc.dim(" · "));
296
+ return [label, pc.dim(tags)];
297
+ });
298
+ console.log("");
299
+ console.log(renderCard("Discovery paths", renderRows(pathRows), { formatBorder: pc.magenta }));
300
+
301
+ const customDirs = scanDirs.filter((d) => !defaultSet.has(d));
302
+ const choices = [
303
+ { value: "add", label: "Add discovery path" },
304
+ ...(customDirs.length > 0 ? [{ value: "remove", label: "Remove discovery path" }] : []),
305
+ { value: "back", label: "Back to models" },
306
+ ];
307
+ const action = await prompt.choice("Settings", choices, "back");
308
+
309
+ if (!action || action === "back") return;
310
+
311
+ if (action === "add") {
312
+ const dir = await prompt.text("Path to model directory", "");
313
+ if (!dir || !dir.trim()) continue;
314
+ const cleanDir = dir.trim();
315
+ if (!existsSync(cleanDir)) {
316
+ console.log(pc.red(`Directory not found: ${cleanDir}`));
317
+ continue;
318
+ }
319
+ await addModelScanDir(cleanDir);
320
+ console.log(pc.green(`Added: ${cleanDir}`));
321
+ }
322
+
323
+ if (action === "remove") {
324
+ const removeChoices = customDirs.map((d) => ({ value: d, label: d }));
325
+ const toRemove = await prompt.choice("Remove path", removeChoices);
326
+ if (!toRemove) continue;
327
+ await removeModelScanDir(toRemove);
328
+ console.log(pc.green(`Removed: ${toRemove}`));
329
+ }
330
+ }
257
331
  }
@@ -1,9 +1,9 @@
1
- import { existsSync } from "node:fs";
2
- import { ensureDirs, findLlamaServer, hasHomebrew, HF_HUB_DIR } from "../config.mjs";
1
+ import { ensureDirs, findLlamaServer, ensureHomebrewFor, HF_HUB_DIR } from "../config.mjs";
3
2
  import { BACKENDS } from "../backends.mjs";
4
3
  import { scanGgufModels } from "../scan.mjs";
5
4
  import { hasPi } from "../harness-pi.mjs";
6
5
  import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
6
+ import { ensureOmlxRuntime } from "../omlx-runtime.mjs";
7
7
  import { scanManagedModels } from "../managed.mjs";
8
8
  import { BACKEND_INSTALL_CHOICES, BACKEND_INSTALLERS } from "../backend-installers.mjs";
9
9
  import { recommendedModel, selectFormat, allFittingModels } from "../recommendations.mjs";
@@ -24,6 +24,7 @@ export async function onboardFlow() {
24
24
  console.log(pc.dim("Let's make sure you have everything you need to run local models.\n"));
25
25
 
26
26
  const llamaBinary = await ensureLlamaRuntime(prompt);
27
+ await ensureOmlxRuntime(prompt, run);
27
28
  if (!(await ensurePi(prompt, run))) return;
28
29
 
29
30
  const [{ models: ggufModels }, managedModels] = await Promise.all([
@@ -169,35 +170,6 @@ async function offerBackendInstall(prompt, run) {
169
170
  await installBackend(prompt, run, choice, model);
170
171
  }
171
172
 
172
- async function ensureHomebrewFor(prompt, run, label) {
173
- if (await hasHomebrew()) return true;
174
- const install = await prompt.yesNo(`Homebrew is needed to install ${label}. Install Homebrew now?`, true);
175
- if (!install) {
176
- console.log(pc.dim(`Install ${label} manually, or install Homebrew from https://brew.sh and run offgrid-ai again.`));
177
- return false;
178
- }
179
- console.log(pc.cyan("Installing Homebrew..."));
180
- try {
181
- await run("/bin/bash", ["-c", "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""], "Homebrew");
182
- for (const path of ["/opt/homebrew/bin", "/usr/local/bin"]) {
183
- if (existsSync(path)) {
184
- process.env.PATH = `${path}:${process.env.PATH}`;
185
- break;
186
- }
187
- }
188
- } catch {
189
- console.log(pc.red("✗ Homebrew installation failed."));
190
- console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
191
- return false;
192
- }
193
- if (!(await hasHomebrew())) {
194
- console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
195
- return false;
196
- }
197
- console.log(pc.green("✓ Homebrew found"));
198
- return true;
199
- }
200
-
201
173
  async function installBackend(prompt, run, backendId, model) {
202
174
  const installer = BACKEND_INSTALLERS[backendId];
203
175
  if (!(await ensureHomebrewFor(prompt, run, installer.label))) return;
@@ -77,7 +77,7 @@ async function ensureLocalServer(profile, backend, options) {
77
77
  console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
78
78
  console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
79
79
  const textOnly = textOnlyProfile(profile);
80
- await saveProfile(textOnly, { writeCommand: true });
80
+ await saveProfile(textOnly);
81
81
  return { handled: true, result: await runProfile(textOnly, { ...options, textOnlyRetry: true }) };
82
82
  }
83
83
  throw err;
@@ -113,6 +113,7 @@ async function launchHarness(profile, options, isManaged, withHarness, backend)
113
113
  }
114
114
 
115
115
  if (!(await hasPiModel(profile))) await syncPiConfig(profile);
116
+
116
117
  try {
117
118
  await launchPi(profile);
118
119
  } finally {
@@ -121,10 +122,6 @@ async function launchHarness(profile, options, isManaged, withHarness, backend)
121
122
  const result = await stopProfile(profile);
122
123
  console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
123
124
  } else {
124
- // Managed-server backends (oMLX): unload the model from the
125
- // server's memory via its HTTP API. The server itself stays running
126
- // (offgrid-ai doesn't manage it), but the model is released — same UX
127
- // as local-server backends where stopProfile kills the process.
128
125
  const result = await unloadModelFromServer(profile);
129
126
  if (result.unloaded) {
130
127
  console.log(pc.green(`[unload] ${backend.label}: model unloaded`));
package/src/config.mjs CHANGED
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { readFile, writeFile } from "node:fs/promises";
6
+ import { pc } from "./ui.mjs";
6
7
 
7
8
  // ── Base directories ──────────────────────────────────────────────────────
8
9
 
@@ -25,6 +26,7 @@ export const HF_HUB_DIR = process.env.HF_HUB_CACHE
25
26
 
26
27
  export const DEFAULT_MODEL_DIRS = [
27
28
  join(homedir(), ".lmstudio", "models"),
29
+ join(homedir(), ".omlx", "models"),
28
30
  HF_HUB_DIR,
29
31
  ];
30
32
 
@@ -46,7 +48,6 @@ const CONFIG_PATH = join(DATA_DIR, "config.json");
46
48
 
47
49
  const DEFAULT_CONFIG = {
48
50
  modelScanDirs: [],
49
- benchmarkRepoPath: null,
50
51
  binaryOverrides: {},
51
52
  };
52
53
 
@@ -77,6 +78,21 @@ export async function getModelScanDirs() {
77
78
  return [...DEFAULT_MODEL_DIRS, ...config.modelScanDirs].filter((dir, i, arr) => arr.indexOf(dir) === i);
78
79
  }
79
80
 
81
+ export async function addModelScanDir(dir) {
82
+ const config = await loadConfig();
83
+ config.modelScanDirs ??= [];
84
+ if (!config.modelScanDirs.includes(dir)) {
85
+ config.modelScanDirs.push(dir);
86
+ await saveConfig(config);
87
+ }
88
+ }
89
+
90
+ export async function removeModelScanDir(dir) {
91
+ const config = await loadConfig();
92
+ config.modelScanDirs = (config.modelScanDirs ?? []).filter((d) => d !== dir);
93
+ await saveConfig(config);
94
+ }
95
+
80
96
  // ── Binary discovery ──────────────────────────────────────────────────────
81
97
 
82
98
  import { execFile } from "node:child_process";
@@ -124,4 +140,49 @@ export async function hasHomebrew() {
124
140
  } catch {
125
141
  return false;
126
142
  }
143
+ }
144
+
145
+ /**
146
+ * Install Homebrew non-interactively and add it to PATH for this process.
147
+ * Returns true if Homebrew is available after installation.
148
+ */
149
+ export async function installHomebrew(run) {
150
+ await run("/bin/bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], "Homebrew");
151
+ for (const path of ["/opt/homebrew/bin", "/usr/local/bin"]) {
152
+ if (existsSync(path)) {
153
+ process.env.PATH = `${path}:${process.env.PATH}`;
154
+ break;
155
+ }
156
+ }
157
+ return await hasHomebrew();
158
+ }
159
+
160
+ /**
161
+ * Ensure Homebrew is installed, prompting the user if necessary.
162
+ * @param {object} prompt - UI prompt interface (needs yesNo)
163
+ * @param {function} run - runCommand function for verbose command execution
164
+ * @param {string} label - what we're installing (for the prompt message)
165
+ * @returns {Promise<boolean>} true if Homebrew is available
166
+ */
167
+ export async function ensureHomebrewFor(prompt, run, label) {
168
+ if (await hasHomebrew()) return true;
169
+ const install = await prompt.yesNo(`Homebrew is needed to install ${label}. Install Homebrew now?`, true);
170
+ if (!install) {
171
+ console.log(pc.dim(`Install ${label} manually, or install Homebrew from https://brew.sh and run offgrid-ai again.`));
172
+ return false;
173
+ }
174
+ console.log(pc.cyan("Installing Homebrew..."));
175
+ try {
176
+ const success = await installHomebrew(run);
177
+ if (!success) {
178
+ console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
179
+ return false;
180
+ }
181
+ } catch {
182
+ console.log(pc.red("✗ Homebrew installation failed."));
183
+ console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
184
+ return false;
185
+ }
186
+ console.log(pc.green("✓ Homebrew found"));
187
+ return true;
127
188
  }