offgrid-ai 0.16.3 → 0.18.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
@@ -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.3",
4
- "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
3
+ "version": "0.18.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
  }
@@ -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"
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
  }
@@ -7,7 +7,7 @@ import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
7
7
  import { offerManagedOmlxUpdate, hasOmlx } from "../omlx-runtime.mjs";
8
8
  import { hasLmStudioInstalled, scanManagedModels } from "../managed.mjs";
9
9
  import { recommendedModel } from "../recommendations.mjs";
10
- import { pc, startInteractive, createPrompt } from "../ui.mjs";
10
+ import { pc, startInteractive, createPrompt, renderCard } from "../ui.mjs";
11
11
  import { onboardFlow } from "./onboard.mjs";
12
12
  import { modelCommandCenter } from "./models.mjs";
13
13
  import { statusCommand } from "./status.mjs";
@@ -58,9 +58,28 @@ export async function mainFlow() {
58
58
  if (!process.stdin.isTTY) return await statusCommand();
59
59
 
60
60
  startInteractive("offgrid-ai");
61
+ printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled: await hasOmlx(), profiles });
62
+ console.log(pc.dim(" No models? Pick \"↓ Download a model\" below — offgrid-ai downloads from HuggingFace"));
63
+ console.log("");
61
64
  return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
62
65
  }
63
66
 
67
+ function printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled, profiles }) {
68
+ const omlxServerUp = managedModels.some((m) => m.backendId === "omlx" && m.status === "ok");
69
+ const parts = [
70
+ llamaBinary ? pc.green("llama.cpp ✓") : pc.red("llama.cpp ✗"),
71
+ ];
72
+ if (omlxInstalled) {
73
+ parts.push(omlxServerUp ? pc.green("oMLX ✓ server up") : pc.yellow("oMLX ✓ server down"));
74
+ } else {
75
+ parts.push(pc.red("oMLX ✗"));
76
+ }
77
+ parts.push(piInstalled ? pc.green("Pi ✓") : pc.red("Pi ✗"));
78
+ if (profiles.length > 0) parts.push(pc.dim(`${profiles.length} model${profiles.length === 1 ? "" : "s"}`));
79
+ console.log(renderCard("offgrid-ai", parts.join(pc.dim(" · ")), { formatBorder: pc.cyan }));
80
+ console.log("");
81
+ }
82
+
64
83
  async function printNoModelsHelp(llamaBinary) {
65
84
  console.log(pc.yellow("No models found."));
66
85
  console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
@@ -1,15 +1,22 @@
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 { rm, unlink } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { stripVTControlCharacters } from "node:util";
2
7
  import { backendFor, BACKENDS } from "../backends.mjs";
3
8
  import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
4
9
  import { isProfileRunning, isProfileServerUp, modelAvailableOnServer, stopProfile } from "../process.mjs";
5
- import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
10
+ import { syncPiConfig, removeFromPiConfig, hasPi } from "../harness-pi.mjs";
11
+ import { hasOmlx, offerOmlxRestart } from "../omlx-runtime.mjs";
6
12
  import { configureLocalProfile, configureManagedProfile } from "../profile-setup.mjs";
7
- import { pc, startInteractive, createPrompt, modelSelect } from "../ui.mjs";
13
+ import { findOmlxModelDir } from "../mlx-discovery.mjs";
14
+ import { pc, startInteractive, createPrompt, modelSelect, renderCard, renderRows } from "../ui.mjs";
8
15
  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";
16
+ import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails, printProfileDetails } from "../model-presenters.mjs";
10
17
  import { runProfile } from "./run.mjs";
11
-
12
- const { stripVTControlCharacters } = await import("node:util");
18
+ import { downloadFlow } from "../download.mjs";
19
+ import { execFileAsync } from "../exec.mjs";
13
20
 
14
21
  export async function modelsCommand(argv) {
15
22
  await ensureDirs();
@@ -31,7 +38,19 @@ export async function modelCommandCenter(initialCatalog) {
31
38
  return;
32
39
  }
33
40
 
34
- const catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
41
+ let catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
42
+
43
+ while (true) {
44
+ const result = await showModelPicker(catalog);
45
+ if (result === "rescan") {
46
+ catalog = await loadModelCatalog();
47
+ continue;
48
+ }
49
+ return;
50
+ }
51
+ }
52
+
53
+ async function showModelPicker(catalog) {
35
54
  const normalized = normalizeCatalog(catalog);
36
55
  const allItems = buildCatalogItems(normalized);
37
56
  if (allItems.length === 0) {
@@ -50,8 +69,14 @@ export async function modelCommandCenter(initialCatalog) {
50
69
  if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
51
70
  }
52
71
  }
53
- printWorkspaceHeader(normalized, runningProfilesNow, modelMissingIds);
54
- await printBenchmarkLine();
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
+ }
55
80
 
56
81
  const nameWidth = modelNameWidth(allItems);
57
82
 
@@ -82,15 +107,10 @@ export async function modelCommandCenter(initialCatalog) {
82
107
  }
83
108
 
84
109
  const groups = [];
85
- const backendColors = {
86
- "llama-cpp": pc.cyan,
87
- omlx: pc.magenta,
88
- };
89
110
  for (const { backendId, sourceId, items } of byBackend.values()) {
90
111
  const backendLabel = backendFor(backendId)?.label ?? backendId;
91
112
  const sourceLabel = formatSourceLabel(sourceId);
92
- const color = backendColors[backendId] ?? pc.dim;
93
- const sep = `Inference: ${pc.bold(color(backendLabel))} ${pc.dim("|")} Source: ${sourceLabel} (${items.length})`;
113
+ const sep = ` ${pc.dim(backendLabel + " · " + sourceLabel + " (" + items.length + ")")}`;
94
114
  const groupItems = items.map((item) => {
95
115
  const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, compact: true });
96
116
  return { value: opt.value, label: opt.label, description: opt.description };
@@ -103,13 +123,31 @@ export async function modelCommandCenter(initialCatalog) {
103
123
  const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, compact: true });
104
124
  return { value: opt.value, label: opt.label, description: opt.description };
105
125
  });
106
- groups.push({ separator: ` ${pc.bold(pc.yellow(`Needs setup (${setupItems.length})`))}`, items: groupItems });
126
+ groups.push({ separator: ` ${pc.yellow("Needs setup (" + setupItems.length + ")")}`, items: groupItems });
107
127
  }
108
128
 
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
+ ] });
133
+
109
134
  const prompt = createPrompt();
110
135
  try {
111
136
  const selected = await modelSelect("Select a model", groups, { pageSize: 20 });
112
137
  if (!selected) return;
138
+
139
+ if (selected === "__settings__") {
140
+ await settingsFlow(prompt);
141
+ console.log("");
142
+ return;
143
+ }
144
+
145
+ if (selected === "__download__") {
146
+ await downloadFlow(prompt);
147
+ console.log("");
148
+ return;
149
+ }
150
+
113
151
  const item = allItems.find((candidate) => itemKey(candidate) === selected);
114
152
  if (!item) return;
115
153
 
@@ -117,6 +155,7 @@ export async function modelCommandCenter(initialCatalog) {
117
155
  const action = await prompt.choice(item.label, actions, actions[0].value);
118
156
  if (!action) return;
119
157
  await performAction(prompt, action, item);
158
+ console.log("");
120
159
  } finally {
121
160
  prompt.close();
122
161
  }
@@ -128,13 +167,13 @@ function formatActions(rawActions) {
128
167
  const width = Math.max(17, maxName + 2);
129
168
  return rawActions.map((a) => {
130
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));
131
- 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);
132
171
  return { value: a.value, label: name + sep + desc };
133
172
  });
134
173
  }
135
174
 
136
175
  function actionsForItem(item) {
137
- const missing = item.type === "profile" && item.fileMissing;
176
+ const missing = item.type === "profile" && item.missing;
138
177
  if (item.type === "profile") {
139
178
  const available = [
140
179
  { value: "inspect", name: "Details", desc: "Paths, ports, flags" },
@@ -144,21 +183,14 @@ function actionsForItem(item) {
144
183
  { value: "run", name: "Start chatting", desc: "Launch and open Pi" },
145
184
  { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings" },
146
185
  );
147
- const backend = backendFor(item.profile.backend);
148
- if (backend.type === "local-server" || backend.type === "managed-server") {
149
- available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run" });
150
- }
151
186
  }
152
- 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" });
153
189
  if (missing) {
154
190
  available.unshift(
155
191
  { value: "run", name: "Start chatting", desc: "Launch and open Pi", dimmed: true },
156
192
  { value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings", dimmed: true },
157
193
  );
158
- const backend = backendFor(item.profile.backend);
159
- if (backend.type === "local-server" || backend.type === "managed-server") {
160
- available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run", dimmed: true });
161
- }
162
194
  }
163
195
  return formatActions(available);
164
196
  }
@@ -166,18 +198,22 @@ function actionsForItem(item) {
166
198
  return formatActions([
167
199
  { value: "setup", name: "Set up", desc: "Configure and save" },
168
200
  { value: "inspect", name: "Details", desc: "Model info" },
201
+ { value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
169
202
  ]);
170
203
  }
171
204
  return formatActions([
172
205
  { value: "setup", name: "Set up", desc: `Connect via ${BACKENDS[item.backendId].label}` },
173
206
  { value: "inspect", name: "Details", desc: "Model info" },
207
+ { value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
174
208
  ]);
175
209
  }
176
210
 
177
211
  async function performAction(prompt, action, item) {
178
- const missing = item.type === "profile" && item.fileMissing;
179
- if (missing && ["run", "reconfigure", "benchmark"].includes(action)) {
180
- console.log(pc.red("This model's file is no longer on disk. Remove the setup or move the file back."));
212
+ const missing = item.type === "profile" && item.missing;
213
+ if (missing && ["run", "reconfigure"].includes(action)) {
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.`));
181
217
  return;
182
218
  }
183
219
  if (action === "inspect") {
@@ -185,17 +221,10 @@ async function performAction(prompt, action, item) {
185
221
  if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
186
222
  return printGgufModelDetails(item.model, item.drafter);
187
223
  }
188
- if (action === "benchmark") {
189
- if (item.type === "profile") {
190
- const { benchmarkForProfile } = await import("../benchmark.mjs");
191
- return await benchmarkForProfile(await readProfile(item.profile.id));
192
- }
193
- const { benchmarkFlow } = await import("../benchmark.mjs");
194
- return await benchmarkFlow();
195
- }
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) {
@@ -255,4 +284,232 @@ async function removeProfileInteractive(id) {
255
284
  await removeFromPiConfig(profile);
256
285
  await deleteProfile(id);
257
286
  console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
287
+ }
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
+
444
+ // ── Settings & discovery path management ───────────────────────────────────
445
+
446
+ async function settingsFlow(prompt) {
447
+ while (true) {
448
+ const llamaBinary = await findLlamaServer();
449
+ const omlxInstalled = await hasOmlx();
450
+ const piInstalled = await hasPi();
451
+
452
+ let omlxServerUp = false;
453
+ if (omlxInstalled) {
454
+ try {
455
+ const res = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(2000) });
456
+ omlxServerUp = res.ok;
457
+ } catch { /* server down */ }
458
+ }
459
+
460
+ console.log("");
461
+ console.log(renderCard("Runtime status", renderRows([
462
+ ["llama.cpp", llamaBinary ? pc.green("✓ ") + pc.dim(llamaBinary) : pc.red("✗ not found")],
463
+ ["oMLX", omlxInstalled ? (omlxServerUp ? pc.green("✓ server up") : pc.yellow("✓ installed · server down")) : pc.red("✗ not found")],
464
+ ["Pi", piInstalled ? pc.green("✓ installed") : pc.red("✗ not found")],
465
+ ]), { formatBorder: pc.cyan }));
466
+
467
+ const scanDirs = await getModelScanDirs();
468
+ const defaultSet = new Set(DEFAULT_MODEL_DIRS);
469
+ const pathLabels = new Map([
470
+ [join(homedir(), ".lmstudio", "models"), "LM Studio downloads"],
471
+ [join(homedir(), ".omlx", "models"), "oMLX downloads"],
472
+ [HF_HUB_DIR, "HuggingFace CLI downloads"],
473
+ ]);
474
+ const pathRows = scanDirs.map((dir) => {
475
+ const exists = existsSync(dir);
476
+ const isBuiltin = defaultSet.has(dir);
477
+ const desc = pathLabels.get(dir);
478
+ const label = `${exists ? pc.green("✓") : pc.red("✗")} ${dir}`;
479
+ const tags = [desc, isBuiltin ? "built-in" : "custom"].filter(Boolean).join(pc.dim(" · "));
480
+ return [label, pc.dim(tags)];
481
+ });
482
+ console.log("");
483
+ console.log(renderCard("Discovery paths", renderRows(pathRows), { formatBorder: pc.magenta }));
484
+
485
+ const customDirs = scanDirs.filter((d) => !defaultSet.has(d));
486
+ const choices = [
487
+ { value: "add", label: "Add discovery path" },
488
+ ...(customDirs.length > 0 ? [{ value: "remove", label: "Remove discovery path" }] : []),
489
+ { value: "done", label: "Done" },
490
+ ];
491
+ const action = await prompt.choice("Settings", choices, "done");
492
+
493
+ if (!action || action === "done") return;
494
+
495
+ if (action === "add") {
496
+ const dir = await prompt.text("Path to model directory", "");
497
+ if (!dir || !dir.trim()) continue;
498
+ const cleanDir = dir.trim();
499
+ if (!existsSync(cleanDir)) {
500
+ console.log(pc.red(`Directory not found: ${cleanDir}`));
501
+ continue;
502
+ }
503
+ await addModelScanDir(cleanDir);
504
+ console.log(pc.green(`Added: ${cleanDir}`));
505
+ }
506
+
507
+ if (action === "remove") {
508
+ const removeChoices = customDirs.map((d) => ({ value: d, label: d }));
509
+ const toRemove = await prompt.choice("Remove path", removeChoices);
510
+ if (!toRemove) continue;
511
+ await removeModelScanDir(toRemove);
512
+ console.log(pc.green(`Removed: ${toRemove}`));
513
+ }
514
+ }
258
515
  }