offgrid-ai 0.17.0 → 0.18.1

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 and oMLX.**
5
+ **Run local AI models on your machine pick, configure, and chat.**
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,19 +12,23 @@
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 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 has 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
- This is the recommended workflow:
17
+ The recommended workflow:
18
18
 
19
- 1. Download models from **LM Studio** or **oMLX**
20
- 2. Do minimal configuration using the `offgrid-ai` command
21
- 3. Run the model with `offgrid-ai` with Pi in interactive mode
19
+ 1. Download models from **HuggingFace** (or use models you already have from LM Studio, oMLX, etc.)
20
+ 2. Configure using the `offgrid-ai` interactive setup
21
+ 3. Start chatting in **Pi** offgrid-ai handles the server lifecycle
22
22
 
23
23
  ## Core Features
24
- - Auto-detects available models from LM Studio, oMLX, and HuggingFace
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, oMLX auto-start and cache management)
27
- - Start / stop local servers automatically for chat sessions (llama-server and oMLX)
24
+
25
+ - **Download models** from HuggingFace with a quant picker and RAM fit indicators
26
+ - **Auto-detects** models from LM Studio, oMLX, and HuggingFace cache
27
+ - **Glass-box setup** every configuration flag gets an explanation card with tradeoffs and memory impact
28
+ - **Model management** — delete models from disk, remove configurations, reconfigure settings
29
+ - **Auto-detects MTP** (multi-token prediction) and **QAT** (quantization-aware training) models, applies the correct flags
30
+ - **Start / stop servers** automatically for chat sessions (llama-server and oMLX)
31
+ - **oMLX integration** — auto-start, MTP enable via admin API, restart after download/deletion
28
32
 
29
33
  ## Quick start
30
34
 
@@ -52,7 +56,7 @@ The curl installer is recommended for first-time setup because it also verifies
52
56
 
53
57
  ### 2. Pick a model
54
58
 
55
- The first time you run offgrid-ai, it looks for models already on your machine. If it does not find any, it tells you how to get one.
59
+ The first time you run offgrid-ai, it looks for models already on your machine. If it doesn't find any, you can download one directly from HuggingFace — just pick "↓ Download a model" and enter a repo ID (e.g. `unsloth/gemma-4-E2B-it-GGUF`).
56
60
 
57
61
  <img width="808" height="274" alt="image" src="https://github.com/user-attachments/assets/6e1583ab-65db-423c-b0eb-b627586fbf86" />
58
62
 
@@ -74,16 +78,17 @@ Pick a model from the list and press Enter. offgrid-ai configures the rest and o
74
78
  ## Everyday commands
75
79
 
76
80
  ```bash
77
- offgrid-ai # primary entry-point for the CLI
81
+ offgrid-ai # model picker pick, configure, download, or manage models
78
82
  offgrid-ai status # see if any model is running
79
83
  offgrid-ai stop # stop the running model
80
84
  offgrid-ai uninstall # remove offgrid-ai
81
85
  ```
82
86
 
83
- ## What can I do with it?
87
+ ## Platform support
84
88
 
85
- - **Chat with local models** — you download the models yourself, and then offgrid-ai helps configure and run then
86
- - **Keep data private** — everything runs on your machine without any cloud connections
89
+ - **macOS (Apple Silicon)** — full support: llama.cpp (GGUF) + oMLX (MLX)
90
+ - **Linux** — llama.cpp (GGUF) only. oMLX is Apple Silicon exclusive.
91
+ - **Windows** — not supported
87
92
 
88
93
  ## Need help?
89
94
 
@@ -104,4 +109,4 @@ node bin/offgrid-ai.mjs
104
109
 
105
110
  ## License
106
111
 
107
- Personal project by [Eeshan Srivastava](https://eeshans.com).
112
+ Personal project by [Eeshan Srivastava](https://eeshans.com).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
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",
@@ -2,56 +2,56 @@
2
2
  "models": [
3
3
  {
4
4
  "id": "gemma-4-e2b",
5
- "label": "Gemma 4 E2B",
5
+ "label": "Gemma 4 E2B (Q4_K_S)",
6
6
  "minRamGb": 8,
7
7
  "gguf": "unsloth/gemma-4-E2B-it-GGUF/gemma-4-E2B-it-Q4_K_S.gguf",
8
8
  "mlx": "mlx-community/gemma-4-e2b-it-4bit"
9
9
  },
10
10
  {
11
11
  "id": "qwen-3.5-9b",
12
- "label": "Qwen 3.5 9B",
12
+ "label": "Qwen 3.5 9B (Q4_K_S)",
13
13
  "minRamGb": 16,
14
14
  "gguf": "unsloth/Qwen3.5-9B-GGUF/Qwen3.5-9B-UD-Q4_K_S.gguf",
15
15
  "mlx": "lmstudio-community/Qwen3.5-9B-MLX-4bit"
16
16
  },
17
17
  {
18
18
  "id": "gemma-4-12b-qat",
19
- "label": "Gemma 4 12B",
19
+ "label": "Gemma 4 12B (Q4_K_XL)",
20
20
  "minRamGb": 24,
21
21
  "gguf": "unsloth/gemma-4-12B-it-qat-GGUF/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
22
22
  "mlx": "mlx-community/gemma-4-12B-it-qat-4bit"
23
23
  },
24
24
  {
25
25
  "id": "gemma-4-26b",
26
- "label": "Gemma 4 26B",
26
+ "label": "Gemma 4 26B (Q4_K_XL)",
27
27
  "minRamGb": 32,
28
28
  "gguf": "unsloth/gemma-4-26B-A4B-it-qat-GGUF/gemma-4-26B-A4B-it-qat-UD-Q4_K_XL.gguf",
29
29
  "mlx": "mlx-community/gemma-4-26b-a4b-4bit"
30
30
  },
31
31
  {
32
32
  "id": "qwen-3.6-35b-compact",
33
- "label": "Qwen 3.6 35B",
33
+ "label": "Qwen 3.6 35B (Q4_K_S)",
34
34
  "minRamGb": 32,
35
35
  "gguf": "unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_S.gguf",
36
36
  "mlx": "mlx-community/Qwen3.6-35B-A3B-4bit"
37
37
  },
38
38
  {
39
39
  "id": "qwen-3.6-35b",
40
- "label": "Qwen 3.6 35B",
40
+ "label": "Qwen 3.6 35B (Q4_K_M)",
41
41
  "minRamGb": 48,
42
42
  "gguf": "unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf",
43
43
  "mlx": ""
44
44
  },
45
45
  {
46
46
  "id": "gemma-4-31b",
47
- "label": "Gemma 4 31B",
47
+ "label": "Gemma 4 31B (Q4_K_XL)",
48
48
  "minRamGb": 64,
49
49
  "gguf": "unsloth/gemma-4-31B-it-qat-GGUF/gemma-4-31B-it-qat-UD-Q4_K_XL.gguf",
50
50
  "mlx": "mlx-community/gemma-4-31b-4bit"
51
51
  },
52
52
  {
53
53
  "id": "qwen-3.6-27b",
54
- "label": "Qwen 3.6 27B",
54
+ "label": "Qwen 3.6 27B (Q4_K_M)",
55
55
  "minRamGb": 64,
56
56
  "gguf": "unsloth/Qwen3.6-27B-MTP-GGUF/Qwen3.6-27B-Q4_K_M.gguf",
57
57
  "mlx": "mlx-community/Qwen3.6-27B-4bit"
@@ -59,10 +59,7 @@ export async function mainFlow() {
59
59
 
60
60
  startInteractive("offgrid-ai");
61
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"));
62
+ console.log(pc.dim(" No models? Pick \"↓ Download a model\" below — offgrid-ai downloads from HuggingFace"));
66
63
  console.log("");
67
64
  return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
68
65
  }
@@ -1,19 +1,22 @@
1
1
  import { ensureDirs, getModelScanDirs, addModelScanDir, removeModelScanDir, DEFAULT_MODEL_DIRS, findLlamaServer, HF_HUB_DIR } from "../config.mjs";
2
2
  import { existsSync } from "node:fs";
3
+ import { rm, unlink } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
6
+ import { stripVTControlCharacters } from "node:util";
5
7
  import { backendFor, BACKENDS } from "../backends.mjs";
6
8
  import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
7
9
  import { isProfileRunning, isProfileServerUp, modelAvailableOnServer, stopProfile } from "../process.mjs";
8
10
  import { syncPiConfig, removeFromPiConfig, hasPi } from "../harness-pi.mjs";
9
- import { hasOmlx } from "../omlx-runtime.mjs";
11
+ import { hasOmlx, offerOmlxRestart } from "../omlx-runtime.mjs";
10
12
  import { configureLocalProfile, configureManagedProfile } from "../profile-setup.mjs";
13
+ import { findOmlxModelDir } from "../mlx-discovery.mjs";
11
14
  import { pc, startInteractive, createPrompt, modelSelect, renderCard, renderRows } from "../ui.mjs";
12
15
  import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
13
16
  import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails, printProfileDetails } from "../model-presenters.mjs";
14
17
  import { runProfile } from "./run.mjs";
15
-
16
- const { stripVTControlCharacters } = await import("node:util");
18
+ import { downloadFlow } from "../download.mjs";
19
+ import { execFileAsync } from "../exec.mjs";
17
20
 
18
21
  export async function modelsCommand(argv) {
19
22
  await ensureDirs();
@@ -66,6 +69,15 @@ async function showModelPicker(catalog) {
66
69
  if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
67
70
  }
68
71
  }
72
+ // Flag all missing profiles (file missing for llama.cpp, model missing
73
+ // for oMLX managed-server) so actionsForItem/performAction can handle both
74
+ // cases uniformly.
75
+ for (const item of allItems) {
76
+ if (item.type === "profile") {
77
+ item.missing = item.fileMissing || modelMissingIds.has(item.profile.id);
78
+ }
79
+ }
80
+
69
81
  const nameWidth = modelNameWidth(allItems);
70
82
 
71
83
  const statusFor = (item) => {
@@ -114,7 +126,10 @@ async function showModelPicker(catalog) {
114
126
  groups.push({ separator: ` ${pc.yellow("Needs setup (" + setupItems.length + ")")}`, items: groupItems });
115
127
  }
116
128
 
117
- groups.push({ separator: " ", items: [{ value: "__settings__", label: `${pc.dim("○")} ${pc.cyan("⚙ Status & settings")}` }] });
129
+ groups.push({ separator: " ", items: [
130
+ { value: "__download__", label: `${pc.dim("○")} ${pc.green("↓ Download a model")}` },
131
+ { value: "__settings__", label: `${pc.dim("○")} ${pc.cyan("⚙ Status & settings")}` },
132
+ ] });
118
133
 
119
134
  const prompt = createPrompt();
120
135
  try {
@@ -123,7 +138,14 @@ async function showModelPicker(catalog) {
123
138
 
124
139
  if (selected === "__settings__") {
125
140
  await settingsFlow(prompt);
126
- return "rescan";
141
+ console.log("");
142
+ return;
143
+ }
144
+
145
+ if (selected === "__download__") {
146
+ await downloadFlow(prompt);
147
+ console.log("");
148
+ return;
127
149
  }
128
150
 
129
151
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
@@ -133,6 +155,7 @@ async function showModelPicker(catalog) {
133
155
  const action = await prompt.choice(item.label, actions, actions[0].value);
134
156
  if (!action) return;
135
157
  await performAction(prompt, action, item);
158
+ console.log("");
136
159
  } finally {
137
160
  prompt.close();
138
161
  }
@@ -144,13 +167,13 @@ function formatActions(rawActions) {
144
167
  const width = Math.max(17, maxName + 2);
145
168
  return rawActions.map((a) => {
146
169
  const name = a.dimmed ? pc.dim(pc.strikethrough(a.name.padEnd(width).slice(0, width))) : pc.bold(a.name.padEnd(width).slice(0, width));
147
- const desc = a.dimmed ? pc.red("file not found") : pc.dim(a.desc);
170
+ const desc = a.dimmed ? pc.red("not available") : pc.dim(a.desc);
148
171
  return { value: a.value, label: name + sep + desc };
149
172
  });
150
173
  }
151
174
 
152
175
  function actionsForItem(item) {
153
- const missing = item.type === "profile" && item.fileMissing;
176
+ const missing = item.type === "profile" && item.missing;
154
177
  if (item.type === "profile") {
155
178
  const available = [
156
179
  { value: "inspect", name: "Details", desc: "Paths, ports, flags" },
@@ -161,7 +184,8 @@ function actionsForItem(item) {
161
184
  { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings" },
162
185
  );
163
186
  }
164
- available.push({ value: "remove", name: "Remove", desc: missing ? "Delete this broken setup" : "Delete this setup" });
187
+ available.push({ value: "remove_config", name: "Remove configuration", desc: "Delete this setup, keep model files" });
188
+ available.push({ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" });
165
189
  if (missing) {
166
190
  available.unshift(
167
191
  { value: "run", name: "Start chatting", desc: "Launch and open Pi", dimmed: true },
@@ -174,18 +198,22 @@ function actionsForItem(item) {
174
198
  return formatActions([
175
199
  { value: "setup", name: "Set up", desc: "Configure and save" },
176
200
  { value: "inspect", name: "Details", desc: "Model info" },
201
+ { value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
177
202
  ]);
178
203
  }
179
204
  return formatActions([
180
205
  { value: "setup", name: "Set up", desc: `Connect via ${BACKENDS[item.backendId].label}` },
181
206
  { value: "inspect", name: "Details", desc: "Model info" },
207
+ { value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
182
208
  ]);
183
209
  }
184
210
 
185
211
  async function performAction(prompt, action, item) {
186
- const missing = item.type === "profile" && item.fileMissing;
212
+ const missing = item.type === "profile" && item.missing;
187
213
  if (missing && ["run", "reconfigure"].includes(action)) {
188
- console.log(pc.red("This model's file is no longer on disk. Remove the setup or move the file back."));
214
+ const backend = item.type === "profile" ? backendFor(item.profile.backend) : null;
215
+ const reason = backend?.type === "managed-server" ? "model is no longer available on the server" : "model file is no longer on disk";
216
+ console.log(pc.red(`This model's ${reason}. Remove the setup or restore the model.`));
189
217
  return;
190
218
  }
191
219
  if (action === "inspect") {
@@ -195,7 +223,8 @@ async function performAction(prompt, action, item) {
195
223
  }
196
224
  if (action === "run") return await runItem(item);
197
225
  if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item);
198
- if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
226
+ if (action === "remove_config" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
227
+ if (action === "delete_model") return await deleteModelFromSource(prompt, item);
199
228
  }
200
229
 
201
230
  async function runItem(item) {
@@ -257,6 +286,161 @@ async function removeProfileInteractive(id) {
257
286
  console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
258
287
  }
259
288
 
289
+ // ── Delete model from source ───────────────────────────────────────────────
290
+
291
+ /** Extract HuggingFace repo ID from a cache path. */
292
+ function hfRepoFromPath(path) {
293
+ const hubPart = path.slice(HF_HUB_DIR.length);
294
+ const match = hubPart.match(/models--(.+?)(?=\/|$)/);
295
+ if (!match) return null;
296
+ return match[1].replace(/--/g, "/");
297
+ }
298
+
299
+ /** Determine where a model's files live on disk. */
300
+ async function modelLocationForItem(item) {
301
+ if (item.type === "profile") {
302
+ const backend = backendFor(item.profile.backend);
303
+ if (backend.type === "managed-server") {
304
+ const modelId = item.profile.omlxModel || item.profile.modelAlias || item.profile.id;
305
+ // oMLX model IDs may not include the org prefix, so search recursively
306
+ const dir = await findOmlxModelDir(modelId);
307
+ return { kind: "mlx", dir: dir ?? join(homedir(), ".omlx", "models", ...modelId.replace(/--/g, "/").split("/").filter(Boolean)), modelId };
308
+ }
309
+ const modelPath = item.profile.modelPath;
310
+ if (!modelPath) return { kind: "unknown" };
311
+ if (modelPath.startsWith(HF_HUB_DIR)) {
312
+ return { kind: "hf-cache", path: modelPath, repoId: hfRepoFromPath(modelPath) };
313
+ }
314
+ return { kind: "file", path: modelPath };
315
+ }
316
+ if (item.type === "new") {
317
+ const modelPath = item.model?.path;
318
+ if (!modelPath) return { kind: "unknown" };
319
+ if (modelPath.startsWith(HF_HUB_DIR)) {
320
+ return { kind: "hf-cache", path: modelPath, repoId: hfRepoFromPath(modelPath) };
321
+ }
322
+ return { kind: "file", path: modelPath };
323
+ }
324
+ if (item.type === "managed") {
325
+ const modelId = item.model?.id;
326
+ if (!modelId) return { kind: "unknown" };
327
+ // oMLX model IDs may not include the org prefix, so search recursively
328
+ const dir = await findOmlxModelDir(modelId);
329
+ return { kind: "mlx", dir: dir ?? join(homedir(), ".omlx", "models", ...modelId.replace(/--/g, "/").split("/").filter(Boolean)), modelId };
330
+ }
331
+ return { kind: "unknown" };
332
+ }
333
+
334
+ async function deleteModelFromSource(prompt, item) {
335
+ const loc = await modelLocationForItem(item);
336
+
337
+ if (loc.kind === "unknown") {
338
+ console.log(pc.yellow("Could not determine where this model's files are located."));
339
+ return;
340
+ }
341
+
342
+ // Show what will be deleted
343
+ let locationLabel;
344
+ if (loc.kind === "hf-cache") {
345
+ locationLabel = loc.path ?? loc.repoId;
346
+ } else if (loc.kind === "mlx") {
347
+ locationLabel = loc.dir;
348
+ } else if (loc.kind === "file") {
349
+ locationLabel = loc.path;
350
+ }
351
+
352
+ console.log(pc.yellow("\nThis will permanently delete " + (item.type === "profile" ? "the configuration and the model from:" : "the model from:")));
353
+ console.log(pc.dim(` ${locationLabel}`));
354
+
355
+ const confirmed = await prompt.yesNo("Delete this model?", false);
356
+ if (!confirmed) {
357
+ console.log(pc.dim("Cancelled."));
358
+ return;
359
+ }
360
+
361
+ // Stop running server if needed
362
+ if (item.type === "profile" && await isProfileRunning(item.profile)) {
363
+ console.log(pc.dim("Stopping running server..."));
364
+ await stopProfile(item.profile);
365
+ }
366
+
367
+ // Delete files
368
+ if (loc.kind === "hf-cache" && loc.repoId) {
369
+ const cacheDir = join(HF_HUB_DIR, `models--${loc.repoId.replace(/\//g, "--")}`);
370
+ try {
371
+ const { stdout } = await execFileAsync("hf", ["cache", "rm", `model/${loc.repoId}`, "--yes"], { timeout: 30000 });
372
+ if (stdout.trim()) console.log(pc.dim(stdout.trim()));
373
+ // Verify the directory is actually gone
374
+ if (existsSync(cacheDir)) {
375
+ console.log(pc.red(`✗ Model still exists at ${cacheDir}`));
376
+ console.log(pc.dim(`Delete manually: hf cache rm model/${loc.repoId}`));
377
+ } else {
378
+ console.log(pc.green(`✓ Deleted ${loc.repoId} from HuggingFace cache`));
379
+ }
380
+ } catch (err) {
381
+ const detail = err.stderr?.trim() || err.message;
382
+ console.log(pc.red(`✗ Failed: ${detail}`));
383
+ console.log(pc.dim(`Delete manually: hf cache rm model/${loc.repoId}`));
384
+ }
385
+ } else if (loc.kind === "mlx") {
386
+ const omlxModelsRoot = join(homedir(), ".omlx", "models");
387
+ // Safety guard: never delete outside ~/.omlx/models/
388
+ if (!loc.dir.startsWith(omlxModelsRoot + "/") && loc.dir !== omlxModelsRoot) {
389
+ console.log(pc.red(`✗ Refusing to delete: path is outside ~/.omlx/models/`));
390
+ console.log(pc.dim(` Target: ${loc.dir}`));
391
+ console.log(pc.dim(`Delete manually if needed: rm -rf ${loc.dir}`));
392
+ return;
393
+ }
394
+ if (!existsSync(loc.dir)) {
395
+ console.log(pc.yellow(`Directory not found: ${loc.dir}`));
396
+ console.log(pc.dim("Model files may have already been removed, or oMLX loaded them from a different location."));
397
+ } else {
398
+ try {
399
+ await rm(loc.dir, { recursive: true, force: true });
400
+ } catch (err) {
401
+ console.log(pc.red(`✗ Failed: ${err.message}`));
402
+ console.log(pc.dim(`Delete manually: rm -rf ${loc.dir}`));
403
+ return;
404
+ }
405
+ // Verify deletion
406
+ if (existsSync(loc.dir)) {
407
+ console.log(pc.red(`✗ Directory still exists: ${loc.dir}`));
408
+ console.log(pc.dim(`Delete manually: rm -rf ${loc.dir}`));
409
+ } else {
410
+ console.log(pc.green(`✓ Deleted ${loc.dir}`));
411
+ await offerOmlxRestart(prompt, "to update its model list");
412
+ }
413
+ }
414
+ } else if (loc.kind === "file") {
415
+ if (!existsSync(loc.path)) {
416
+ console.log(pc.yellow(`File not found: ${loc.path}`));
417
+ console.log(pc.dim("Model file may have already been removed."));
418
+ } else {
419
+ try {
420
+ await unlink(loc.path);
421
+ } catch (err) {
422
+ console.log(pc.red(`✗ Failed: ${err.message}`));
423
+ console.log(pc.dim(`Delete manually: rm ${loc.path}`));
424
+ return;
425
+ }
426
+ // Verify deletion
427
+ if (existsSync(loc.path)) {
428
+ console.log(pc.red(`✗ File still exists: ${loc.path}`));
429
+ console.log(pc.dim(`Delete manually: rm ${loc.path}`));
430
+ } else {
431
+ console.log(pc.green(`✓ Deleted ${loc.path}`));
432
+ }
433
+ }
434
+ }
435
+
436
+ // Remove profile configuration if one exists
437
+ if (item.type === "profile") {
438
+ await removeFromPiConfig(item.profile);
439
+ await deleteProfile(item.profile.id);
440
+ console.log(pc.dim(`Removed configuration: ${item.profile.id}`));
441
+ }
442
+ }
443
+
260
444
  // ── Settings & discovery path management ───────────────────────────────────
261
445
 
262
446
  async function settingsFlow(prompt) {
@@ -268,7 +452,7 @@ async function settingsFlow(prompt) {
268
452
  let omlxServerUp = false;
269
453
  if (omlxInstalled) {
270
454
  try {
271
- const res = await fetch("http://127.0.0.1:8000/v1/models", { signal: AbortSignal.timeout(2000) });
455
+ const res = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(2000) });
272
456
  omlxServerUp = res.ok;
273
457
  } catch { /* server down */ }
274
458
  }
@@ -302,11 +486,11 @@ async function settingsFlow(prompt) {
302
486
  const choices = [
303
487
  { value: "add", label: "Add discovery path" },
304
488
  ...(customDirs.length > 0 ? [{ value: "remove", label: "Remove discovery path" }] : []),
305
- { value: "back", label: "Back to models" },
489
+ { value: "done", label: "Done" },
306
490
  ];
307
- const action = await prompt.choice("Settings", choices, "back");
491
+ const action = await prompt.choice("Settings", choices, "done");
308
492
 
309
- if (!action || action === "back") return;
493
+ if (!action || action === "done") return;
310
494
 
311
495
  if (action === "add") {
312
496
  const dir = await prompt.text("Path to model directory", "");
@@ -1,16 +1,13 @@
1
- import { ensureDirs, findLlamaServer, ensureHomebrewFor, HF_HUB_DIR } from "../config.mjs";
1
+ import { ensureDirs, findLlamaServer } from "../config.mjs";
2
2
  import { BACKENDS } from "../backends.mjs";
3
3
  import { scanGgufModels } from "../scan.mjs";
4
4
  import { hasPi } from "../harness-pi.mjs";
5
5
  import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
6
6
  import { ensureOmlxRuntime } from "../omlx-runtime.mjs";
7
7
  import { scanManagedModels } from "../managed.mjs";
8
- import { BACKEND_INSTALL_CHOICES, BACKEND_INSTALLERS } from "../backend-installers.mjs";
9
- import { recommendedModel, selectFormat, allFittingModels } from "../recommendations.mjs";
10
- import { hasHuggingfaceHub, resolveHfDownload, downloadToHfCache } from "../huggingface.mjs";
11
- import { detectHardware, getFreeDiskBytes, installedRamGB } from "../hardware.mjs";
8
+ import { downloadFlow } from "../download.mjs";
12
9
  import { runCommand } from "../exec.mjs";
13
- import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
10
+ import { pc, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
14
11
 
15
12
  export async function onboardFlow() {
16
13
  await ensureDirs();
@@ -37,12 +34,10 @@ export async function onboardFlow() {
37
34
  if (hasModels) {
38
35
  printFoundModels(ggufModels, managedModels, llamaBinary);
39
36
  } else {
40
- const canDownload = await hasHuggingfaceHub();
41
- if (canDownload) {
42
- const downloaded = await offerModelDownload(prompt);
43
- if (downloaded) return;
37
+ const downloaded = await downloadFlow(prompt);
38
+ if (!downloaded) {
39
+ console.log(pc.dim("\nRun offgrid-ai again when you've downloaded a model."));
44
40
  }
45
- await offerBackendInstall(prompt, run);
46
41
  return;
47
42
  }
48
43
 
@@ -109,98 +104,3 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
109
104
  }
110
105
  }
111
106
 
112
- async function offerModelDownload(prompt) {
113
- const hardware = detectHardware();
114
- const candidates = allFittingModels(hardware)
115
- .map((entry) => ({ entry, format: selectFormat(entry, hardware) }))
116
- .filter((item) => item.format === "gguf");
117
- if (candidates.length === 0) {
118
- console.log(pc.yellow("No curated models fit your hardware."));
119
- return false;
120
- }
121
-
122
- const primary = candidates[0];
123
- console.log(renderSection("Download a recommended model", renderRows([
124
- ["Model", pc.bold(primary.entry.label)],
125
- ["Format", primary.format],
126
- ["Minimum RAM", String(primary.entry.minRamGb) + " GB"],
127
- ["Your RAM", installedRamGB() + " GB"],
128
- ]), { formatBorder: pc.cyan }));
129
-
130
- const shouldDownload = await prompt.yesNo("Download " + primary.entry.label + " (" + primary.format + ")?", true);
131
- if (!shouldDownload) return false;
132
-
133
- const hfRef = primary.entry.gguf;
134
- try {
135
- const plan = await resolveHfDownload(hfRef);
136
- console.log(pc.dim("Total size: " + formatBytes(plan.totalSizeBytes)));
137
- const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
138
- if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
139
- console.log(pc.red(`Not enough disk space in ${HF_HUB_DIR}: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
140
- return false;
141
- }
142
- await downloadToHfCache(plan, {
143
- onProgress({ percentage }) {
144
- process.stdout.write(pc.cyan("\r " + percentage + "% downloaded"));
145
- },
146
- });
147
- process.stdout.write("\n");
148
- console.log(pc.green("✓ Download complete. Run offgrid-ai to use the model."));
149
- return true;
150
- } catch (err) {
151
- console.log(pc.red("Download failed: " + err.message));
152
- return false;
153
- }
154
- }
155
-
156
- async function offerBackendInstall(prompt, run) {
157
- console.log(pc.yellow("\nNo models found."));
158
- console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
159
- const choice = await prompt.choice("Install a model backend?", BACKEND_INSTALL_CHOICES, "lmstudio");
160
- const model = recommendedModel();
161
-
162
- if (choice === "skip") {
163
- console.log(pc.dim("Run offgrid-ai again when you've set up a model backend."));
164
- return;
165
- }
166
- if (choice === "all") {
167
- await installAllBackends(prompt, run, model);
168
- return;
169
- }
170
- await installBackend(prompt, run, choice, model);
171
- }
172
-
173
- async function installBackend(prompt, run, backendId, model) {
174
- const installer = BACKEND_INSTALLERS[backendId];
175
- if (!(await ensureHomebrewFor(prompt, run, installer.label))) return;
176
- console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
177
- try {
178
- await runInstallerCommands(run, installer);
179
- installer.success(model);
180
- } catch {
181
- console.log(pc.red(`✗ ${installer.label} installation failed.`));
182
- console.log(pc.dim(installer.failure));
183
- }
184
- }
185
-
186
- async function installAllBackends(prompt, run, model) {
187
- if (!(await ensureHomebrewFor(prompt, run, "model backends"))) return;
188
- const installed = [];
189
- for (const installer of Object.values(BACKEND_INSTALLERS)) {
190
- console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
191
- try {
192
- await runInstallerCommands(run, installer);
193
- installed.push(installer.label);
194
- } catch {
195
- console.log(pc.yellow(installer.allFailure));
196
- }
197
- }
198
- if (installed.length > 0) {
199
- console.log(pc.green(`\n✓ Installed: ${installed.join(", ")}`));
200
- console.log(pc.dim(`Recommended for your machine (${installedRamGB()}GB RAM): ${model.label}`));
201
- }
202
- }
203
-
204
- async function runInstallerCommands(run, installer) {
205
- for (const [cmd, args, label] of installer.commands) await run(cmd, args, label);
206
- }
@@ -46,6 +46,7 @@ export async function runProfile(profile, options = {}) {
46
46
 
47
47
  printMemoryEstimate(profile, isManaged);
48
48
  await launchHarness(profile, options, isManaged, withHarness, backend);
49
+ console.log("");
49
50
  }
50
51
 
51
52
  async function ensureLocalServer(profile, backend, options) {
@@ -85,4 +85,5 @@ export async function statusCommand() {
85
85
  ["Server", profile.baseUrl],
86
86
  ]), { formatBorder: status.ready ? pc.green : pc.yellow }));
87
87
  }
88
+ console.log("");
88
89
  }
@@ -34,6 +34,7 @@ export async function stopCommand(argv) {
34
34
 
35
35
  const targets = selected === "__all" ? running : running.filter((item) => item.profile.id === selected);
36
36
  for (const { profile } of targets) await printStopResult(profile);
37
+ console.log("");
37
38
  } finally {
38
39
  prompt.close();
39
40
  }
@@ -1,6 +1,5 @@
1
- // Shared discovery helpers used by both the GGUF scanner (scan.mjs) and the
2
- // MLX scanner (mlx-discovery.mjs). Keeping these here avoids a cross-dependency
3
- // between the two format-specific scanners.
1
+ // Helpers for the GGUF scanner (scan.mjs). Centralizes model-size and embedding-type
2
+ // constants so they're defined once rather than duplicated across scanners.
4
3
 
5
4
  import { basename, dirname } from "node:path";
6
5