offgrid-ai 0.17.0 → 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.
@@ -0,0 +1,221 @@
1
+ // Model download flow — HuggingFace downloads with quant picker and RAM fit.
2
+ // Used by onboarding (no models found) and the model picker (↓ Download a model).
3
+
4
+ import { hasHfCli, parseHfRef, resolveHfDownload, downloadModel, listGgufFiles, getHfModelInfo, isMlxRepo } from "./huggingface.mjs";
5
+ import { detectHardware, installedRamGB, getFreeDiskBytes } from "./hardware.mjs";
6
+ import { allFittingModels } from "./recommendations.mjs";
7
+ import { parseModelName } from "./model-name.mjs";
8
+ import { HF_HUB_DIR } from "./config.mjs";
9
+ import { offerOmlxRestart } from "./omlx-runtime.mjs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { pc, formatBytes, renderCard, renderRows } from "./ui.mjs";
13
+
14
+ const GB = 1024 ** 3;
15
+
16
+ /**
17
+ * Interactive model download flow.
18
+ * @param {object} prompt - createPrompt() instance
19
+ * @returns {Promise<boolean>} true if a model was downloaded
20
+ */
21
+ export async function downloadFlow(prompt) {
22
+ console.log("");
23
+ const method = await prompt.choice("Download a model", [
24
+ { value: "manual", label: "Enter a HuggingFace repo ID" },
25
+ { value: "recommended", label: "Recommended for your machine" },
26
+ ], "manual");
27
+
28
+ if (!method) return false;
29
+
30
+ let repo, filename;
31
+
32
+ if (method === "recommended") {
33
+ const hardware = detectHardware();
34
+ const models = allFittingModels(hardware);
35
+ if (models.length === 0) {
36
+ console.log(pc.yellow("No recommended models fit your hardware."));
37
+ console.log(pc.dim("You can still enter a repo ID manually."));
38
+ return false;
39
+ }
40
+ const choices = models.map((m) => ({
41
+ value: m,
42
+ label: `${pc.bold(m.label)} ${pc.dim(`(${m.minRamGb} GB RAM min)`)}`,
43
+ }));
44
+ const selected = await prompt.choice("Select a model", choices, choices[0].value);
45
+ if (!selected) return false;
46
+
47
+ // Determine available formats (ignore empty strings)
48
+ const hasGguf = Boolean(selected.gguf);
49
+ const hasMlx = Boolean(selected.mlx && selected.mlx.trim());
50
+
51
+ let format;
52
+ if (hasGguf && hasMlx) {
53
+ // Both available — let the user choose
54
+ const formatChoices = [
55
+ { value: "gguf", label: `GGUF (llama.cpp) — ${selected.gguf.split("/").pop()}` },
56
+ { value: "mlx", label: `MLX (oMLX) — ${selected.mlx}` },
57
+ ];
58
+ // Default to MLX on Apple Silicon, GGUF elsewhere
59
+ const defaultFormat = (hardware.platform === "darwin" && hardware.arch === "arm64") ? "mlx" : "gguf";
60
+ format = await prompt.choice("Download format", formatChoices, defaultFormat);
61
+ if (!format) return false;
62
+ } else if (hasGguf) {
63
+ format = "gguf";
64
+ } else if (hasMlx) {
65
+ format = "mlx";
66
+ } else {
67
+ console.log(pc.yellow("No download path available for this model."));
68
+ return false;
69
+ }
70
+
71
+ if (format === "gguf") {
72
+ const ref = parseHfRef(selected.gguf);
73
+ repo = ref.repo;
74
+ filename = ref.filename;
75
+ } else {
76
+ repo = selected.mlx;
77
+ filename = undefined;
78
+ }
79
+ } else {
80
+ console.log(pc.dim(" Browse models at huggingface.co/models"));
81
+ const input = await prompt.text("HuggingFace repo ID (e.g. unsloth/gemma-4-E2B-it-GGUF)", "");
82
+ if (!input || !input.trim()) return false;
83
+ const ref = parseHfRef(input.trim());
84
+ repo = ref.repo;
85
+ filename = ref.filename;
86
+ }
87
+
88
+ // For GGUF repos without a specific file, show quant picker
89
+ if (!filename) {
90
+ let ggufFiles;
91
+ try {
92
+ ggufFiles = await listGgufFiles(repo);
93
+ } catch (err) {
94
+ console.log(pc.red(`Could not fetch repo info: ${err.message}`));
95
+ return false;
96
+ }
97
+ if (ggufFiles.length > 0) {
98
+ filename = await pickGgufQuant(prompt, repo, ggufFiles);
99
+ if (!filename) return false;
100
+ } else {
101
+ // No GGUF files — check if it's an MLX repo via HF metadata
102
+ let modelInfo;
103
+ try {
104
+ modelInfo = await getHfModelInfo(repo);
105
+ } catch {
106
+ console.log(pc.red(`Could not fetch repo info for ${repo}. Check the repo ID and try again.`));
107
+ return false;
108
+ }
109
+ if (!isMlxRepo(modelInfo)) {
110
+ console.log(pc.yellow(`This repo is not a GGUF or MLX model (library: ${modelInfo.library_name ?? "unknown"}).`));
111
+ console.log(pc.dim("For llama.cpp: look for a repo ending in -GGUF (e.g. org/model-name-GGUF)"));
112
+ console.log(pc.dim("For oMLX: look for a repo in mlx-community/ (e.g. mlx-community/model-name-4bit)"));
113
+ return false;
114
+ }
115
+ // It's MLX — download everything
116
+ }
117
+ }
118
+
119
+ // Check for huggingface_hub
120
+ if (!(await hasHfCli())) {
121
+ console.log(pc.yellow("HuggingFace CLI is required to download models."));
122
+ console.log(pc.dim("Install it: pip3 install huggingface_hub"));
123
+ return false;
124
+ }
125
+
126
+ // Resolve download plan
127
+ const ref = filename ? `${repo}/${filename}` : repo;
128
+ let plan;
129
+ try {
130
+ plan = await resolveHfDownload(ref);
131
+ } catch (err) {
132
+ console.log(pc.red(`Could not resolve download: ${err.message}`));
133
+ return false;
134
+ }
135
+
136
+ // Check disk space
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: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
140
+ return false;
141
+ }
142
+
143
+ console.log(pc.dim(`\nDownloading ${repo}${filename ? `/${filename}` : ""} (${formatBytes(plan.totalSizeBytes)})`));
144
+ if (plan.format === "mlx") {
145
+ const modelParts = repo.split("/").filter(Boolean);
146
+ const localDir = join(homedir(), ".omlx", "models", ...modelParts);
147
+ console.log(pc.dim(`Location: ${localDir}\n`));
148
+ } else {
149
+ console.log(pc.dim(`Location: HF cache (${HF_HUB_DIR})\n`));
150
+ }
151
+
152
+ try {
153
+ if (plan.format === "mlx") {
154
+ // Download directly to ~/.omlx/models/<org>/<model> — oMLX scans this dir
155
+ const modelParts = repo.split("/").filter(Boolean);
156
+ const localDir = join(homedir(), ".omlx", "models", ...modelParts);
157
+ await downloadModel(plan, { localDir });
158
+ console.log(pc.green("\n✓ Download complete."));
159
+ await offerOmlxRestart(prompt, "to load the new model");
160
+ } else {
161
+ await downloadModel(plan);
162
+ console.log(pc.green("\n✓ Download complete. Run offgrid-ai again to see the model in the picker."));
163
+ }
164
+ return true;
165
+ } catch (err) {
166
+ console.log(pc.red("\nDownload failed: " + err.message));
167
+ return false;
168
+ }
169
+ }
170
+
171
+ // ── Quant picker with RAM fit indicators ───────────────────────────────────
172
+
173
+ async function pickGgufQuant(prompt, repo, ggufFiles) {
174
+ const hardware = detectHardware();
175
+ const totalRam = hardware.totalRamBytes;
176
+ const availableRam = totalRam - 4 * GB; // leave 4GB for OS
177
+
178
+ // Sort by size descending (highest quality first)
179
+ const sorted = [...ggufFiles].sort((a, b) => b.sizeBytes - a.sizeBytes);
180
+
181
+ // Find recommended: largest file that fits comfortably
182
+ const recommended = sorted.find((f) => f.sizeBytes + 2 * GB <= availableRam);
183
+
184
+ console.log("");
185
+ console.log(renderCard("Select quantization", renderRows([
186
+ ["Your RAM", `${installedRamGB()} GB`],
187
+ ["Available", `~${formatBytes(availableRam)} (after OS)`],
188
+ ["Rule", "Lower quant = smaller/faster · Higher = better quality"],
189
+ ]), { formatBorder: pc.cyan }));
190
+ console.log("");
191
+
192
+ const choices = sorted.map((file) => {
193
+ const sizeBytes = file.sizeBytes;
194
+ const parsed = parseModelName(file.path, "huggingface");
195
+ const quant = parsed.quant ?? file.path.replace(/\.gguf$/i, "");
196
+
197
+ let indicator, fitLabel;
198
+ if (sizeBytes > availableRam) {
199
+ indicator = pc.red("✗");
200
+ fitLabel = pc.red("won't fit");
201
+ } else if (sizeBytes + 2 * GB > availableRam) {
202
+ indicator = pc.yellow("⚠");
203
+ fitLabel = pc.yellow("tight");
204
+ } else {
205
+ indicator = pc.green("✓");
206
+ fitLabel = pc.green("fits");
207
+ }
208
+
209
+ const isRecommended = recommended && file.path === recommended.path;
210
+ const hint = isRecommended ? "recommended" : undefined;
211
+
212
+ return {
213
+ value: file.path,
214
+ label: `${indicator} ${quant.padEnd(12)} ${formatBytes(sizeBytes).padEnd(10)} ${fitLabel}`,
215
+ ...(hint ? { hint } : {}),
216
+ };
217
+ });
218
+
219
+ const defaultValue = recommended?.path;
220
+ return await prompt.choice("Quantization", choices, defaultValue);
221
+ }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import { spawn } from "node:child_process";
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { promisify } from "node:util";
3
4
  import { PI_CONFIG } from "./config.mjs";
4
5
  import { loadProfiles } from "./profiles.mjs";
5
6
  import { readJson, writeJson } from "./json.mjs";
@@ -69,8 +70,6 @@ export async function launchPi(profile) {
69
70
 
70
71
  export async function hasPi() {
71
72
  try {
72
- const { execFile } = await import("node:child_process");
73
- const { promisify } = await import("node:util");
74
73
  await promisify(execFile)("which", ["pi"]);
75
74
  return true;
76
75
  } catch {
@@ -1,23 +1,19 @@
1
1
  // HuggingFace model download helpers.
2
- // Uses the Python huggingface_hub package (the standard, maintained downloader)
3
- // to download models into the standard HF cache directory.
4
- // Downloads go to ~/.cache/huggingface/hub, NOT a custom offgrid-ai folder.
2
+ // Uses the `hf` CLI (from huggingface_hub) for actual downloads.
3
+ // The interactive model/quant selection happens in download.mjs; here we
4
+ // just hand off to the CLI and let it handle progress bars, resumption, etc.
5
5
 
6
- import { execFile } from "node:child_process";
6
+ import { spawn, execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
- import { join, dirname } from "node:path";
9
8
  import { mkdir } from "node:fs/promises";
10
- import { fileURLToPath } from "node:url";
11
9
  import { HF_HUB_DIR } from "./config.mjs";
12
10
 
13
11
  const execFileAsync = promisify(execFile);
14
12
 
15
- const HF_DOWNLOAD_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "resources", "hf-download.py");
16
-
17
- /** Check whether python3 + huggingface_hub is available. */
18
- export async function hasHuggingfaceHub() {
13
+ /** Check whether the `hf` CLI is available. */
14
+ export async function hasHfCli() {
19
15
  try {
20
- const { stdout } = await execFileAsync("python3", ["-c", "import huggingface_hub; print(huggingface_hub.__version__)"]);
16
+ const { stdout } = await execFileAsync("hf", ["--version"]);
21
17
  return Boolean(stdout.trim());
22
18
  } catch {
23
19
  return false;
@@ -96,6 +92,33 @@ async function getHfTree(repo, { branch = "main", fetchImpl = globalThis.fetch }
96
92
  return await response.json();
97
93
  }
98
94
 
95
+ /** List all GGUF files in a HuggingFace repo with their sizes. */
96
+ export async function listGgufFiles(repo, { fetchImpl = globalThis.fetch } = {}) {
97
+ const tree = await getHfTree(repo, { fetchImpl });
98
+ return tree
99
+ .filter((f) => f.type === "file" && f.path.endsWith(".gguf"))
100
+ .map((f) => ({
101
+ path: f.path,
102
+ sizeBytes: f.lfs?.size ?? f.size ?? 0,
103
+ }))
104
+ .sort((a, b) => a.sizeBytes - b.sizeBytes);
105
+ }
106
+
107
+ /** Fetch model metadata from the HF API. */
108
+ export async function getHfModelInfo(repo, { fetchImpl = globalThis.fetch } = {}) {
109
+ const url = `https://huggingface.co/api/models/${repo}`;
110
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(10000) });
111
+ if (!response.ok) throw new Error(`HuggingFace API error: HTTP ${response.status} for ${repo}`);
112
+ return await response.json();
113
+ }
114
+
115
+ /** Check if a repo is MLX-formatted based on its HF metadata. */
116
+ export function isMlxRepo(modelInfo) {
117
+ if (modelInfo.library_name === "mlx") return true;
118
+ if (Array.isArray(modelInfo.tags) && modelInfo.tags.includes("mlx")) return true;
119
+ return false;
120
+ }
121
+
99
122
  /** Resolve a user-provided HF reference into a download plan. */
100
123
  export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch } = {}) {
101
124
  const { repo, filename } = parseHfRef(input);
@@ -136,74 +159,51 @@ export async function resolveHfDownload(input, { fetchImpl = globalThis.fetch }
136
159
  }
137
160
 
138
161
  /**
139
- * Download a resolved model into the HF hub cache.
162
+ * Download a resolved model using the `hf` CLI.
163
+ * GGUF: downloads single file to HF cache (offgrid-ai scanner finds it there).
164
+ * MLX: downloads full repo to a local directory (oMLX scans ~/.omlx/models).
165
+ * Progress bars are handled natively by the CLI (stdio inherited).
140
166
  * @param {object} model - from resolveHfDownload
141
- * @param {object} options
142
- * @param {function} options.onProgress - ({ downloadedBytes, totalBytes, percentage, file }) => void
167
+ * @param {object} [options]
168
+ * @param {string} [options.localDir] - for MLX: target directory
143
169
  * @returns {Promise<{ localDir: string, format: string }>}
144
170
  */
145
- export async function downloadToHfCache(model, options = {}) {
146
- await mkdir(HF_HUB_DIR, { recursive: true });
171
+ export async function downloadModel(model, options = {}) {
172
+ const args = ["download", model.repo];
173
+ let localDir;
147
174
 
148
- const script = HF_DOWNLOAD_SCRIPT;
149
- const args = ["--repo", model.repo, "--cache-dir", HF_HUB_DIR];
150
175
  if (model.format === "gguf") {
151
- args.push("--file", model.files[0].filename);
176
+ // Single file to HF cache — scanner finds it there
177
+ await mkdir(HF_HUB_DIR, { recursive: true });
178
+ args.push(model.files[0].filename, "--cache-dir", HF_HUB_DIR);
179
+ localDir = HF_HUB_DIR;
180
+ } else if (options.localDir) {
181
+ // Full repo to a flat local directory (oMLX)
182
+ await mkdir(options.localDir, { recursive: true });
183
+ args.push(
184
+ "--local-dir", options.localDir,
185
+ "--exclude", "*.md",
186
+ "--exclude", ".gitattributes",
187
+ "--exclude", "LICENSE",
188
+ "--exclude", ".gitignore",
189
+ );
190
+ localDir = options.localDir;
191
+ } else {
192
+ // Fallback: full repo to HF cache
193
+ await mkdir(HF_HUB_DIR, { recursive: true });
194
+ args.push("--cache-dir", HF_HUB_DIR);
195
+ localDir = HF_HUB_DIR;
152
196
  }
153
197
 
154
- const onProgress = options.onProgress ?? (() => {});
155
-
156
- return new Promise((resolve, reject) => {
157
- const child = execFile("python3", [script, ...args], { env: process.env });
158
-
159
- let stdoutBuf = "";
160
- let downloadedBytes = 0;
161
- let currentFile = null;
162
-
163
- // huggingface_hub streams NDJSON progress events to stdout, one per line.
164
- // Buffer and split on complete newlines so an event split across chunk
165
- // boundaries isn't silently dropped.
166
- const handleLine = (line) => {
167
- if (!line) return;
168
- try {
169
- const event = JSON.parse(line);
170
- if (event.type === "progress") {
171
- downloadedBytes = event.downloadedBytes ?? downloadedBytes;
172
- currentFile = event.file ?? currentFile;
173
- onProgress({
174
- downloadedBytes,
175
- totalBytes: model.totalSizeBytes,
176
- percentage: Math.min(100, Math.round((downloadedBytes / model.totalSizeBytes) * 100)),
177
- file: currentFile,
178
- });
179
- } else if (event.type === "complete") {
180
- resolve({ localDir: event.localDir, format: model.format });
181
- } else if (event.type === "error") {
182
- reject(new Error(event.message));
183
- }
184
- } catch {
185
- // Ignore non-JSON output (progress bars, etc.)
186
- }
187
- };
188
-
189
- child.stdout?.on("data", (chunk) => {
190
- stdoutBuf += String(chunk);
191
- let nl;
192
- while ((nl = stdoutBuf.indexOf("\n")) !== -1) {
193
- handleLine(stdoutBuf.slice(0, nl));
194
- stdoutBuf = stdoutBuf.slice(nl + 1);
195
- }
196
- });
197
-
198
- child.stderr?.on("data", () => {
199
- // huggingface_hub prints progress bars to stderr; ignore.
200
- });
201
-
198
+ const exitCode = await new Promise((resolve, reject) => {
199
+ const child = spawn("hf", args, { stdio: "inherit", env: process.env });
202
200
  child.on("error", reject);
203
- child.on("exit", (code) => {
204
- // Flush any final line that lacked a trailing newline.
205
- if (stdoutBuf.trim()) handleLine(stdoutBuf.trim());
206
- if (code !== 0) reject(new Error(`Download failed with exit code ${code}`));
207
- });
201
+ child.on("exit", resolve);
208
202
  });
203
+
204
+ if (exitCode !== 0) {
205
+ throw new Error(`hf download exited with code ${exitCode}`);
206
+ }
207
+
208
+ return { localDir, format: model.format };
209
209
  }
package/src/managed.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { BACKENDS } from "./backends.mjs";
3
- import { hasOmlx } from "./omlx-runtime.mjs";
4
3
 
5
- export const MANAGED_BACKEND_IDS = ["omlx"];
4
+ const MANAGED_BACKEND_IDS = ["omlx"];
6
5
 
7
6
  export async function scanManagedModels() {
8
7
  const results = [];
@@ -21,7 +20,3 @@ export async function scanManagedModels() {
21
20
  export function hasLmStudioInstalled() {
22
21
  return existsSync("/Applications/LM Studio.app");
23
22
  }
24
-
25
- export async function hasOmlxInstalled() {
26
- return await hasOmlx();
27
- }
@@ -1,12 +1,12 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join } from "node:path";
3
+ import { stripVTControlCharacters } from "node:util";
3
4
  import { backendFor } from "./backends.mjs";
4
5
  import { computeServerCommand, buildStartScript, isProfileRunning } from "./process.mjs";
5
6
  import { profileDir } from "./profiles.mjs";
6
7
  import { pc, formatBytes, renderSectionRows } from "./ui.mjs";
7
8
  import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
8
9
  import { itemKey } from "./model-catalog.mjs";
9
- import { DATA_DIR } from "./config.mjs";
10
10
 
11
11
  const OPTION_SEPARATOR = " ";
12
12
  const OPTION_STATUS_WIDTH = 12;
@@ -15,8 +15,6 @@ const OPTION_SOURCE_WIDTH = 14;
15
15
  const OPTION_QUANT_WIDTH = 10;
16
16
  const OPTION_CTX_WIDTH = 5;
17
17
 
18
- const { stripVTControlCharacters } = await import("node:util");
19
-
20
18
  function optionPad(text, color, width) {
21
19
  const visible = stripVTControlCharacters(String(text)).length;
22
20
  const padding = Math.max(1, width - visible);
@@ -197,26 +195,6 @@ export function inferBackendId(item) {
197
195
  return "llama-cpp";
198
196
  }
199
197
 
200
- export function printWorkspaceHeader(normalized, runningProfilesNow, modelMissingIds = new Set()) {
201
- const profiles = normalized.profiles;
202
- const isRunning = (p) => runningProfilesNow.some((r) => r.id === p.id);
203
- const isMissing = (p) => isProfileFileMissing(p) || modelMissingIds.has(p.id);
204
- const readyCount = profiles.filter((p) => !isMissing(p) && !isRunning(p)).length;
205
- const runningCount = runningProfilesNow.length;
206
- const missingCount = profiles.filter(isMissing).length;
207
- const setupCount = normalized.newModels.length + normalized.managedItems.length;
208
-
209
- const countParts = [];
210
- if (runningCount > 0) countParts.push(pc.green(`${runningCount} running`));
211
- if (readyCount > 0) countParts.push(pc.blue(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
212
- if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
213
- if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
214
-
215
- console.log(` ${countParts.join(pc.dim(" · "))}`);
216
- console.log(pc.dim(` Profiles: ${DATA_DIR}`));
217
- console.log(pc.dim(" ─────────────────────────────────────────────────────────"));
218
- }
219
-
220
198
  export async function printProfileDetails(profile) {
221
199
  const backend = backendFor(profile.backend);
222
200
  const isManaged = backend.type === "managed-server";
@@ -23,7 +23,7 @@ export function capabilitySummary(caps) {
23
23
  return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
24
24
  }
25
25
 
26
- export function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
26
+ function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
27
27
  if (profile.drafterPath) {
28
28
  return detailed ? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`) : pc.green("MTP enabled");
29
29
  }
@@ -34,7 +34,7 @@ export function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
34
34
  return null;
35
35
  }
36
36
 
37
- export function ggufMtpLabel(model, drafter) {
37
+ function ggufMtpLabel(model, drafter) {
38
38
  const caps = detectCapabilities(model.path, model.mmprojPath);
39
39
  if (caps.mtp || Boolean(drafter)) return pc.green("MTP ✓");
40
40
  if (caps.architecture === "gemma4") return pc.yellow("MTP: needs drafter");
@@ -14,7 +14,7 @@ import { join } from "node:path";
14
14
  import { promisify } from "node:util";
15
15
  import { compareVersions } from "./updates.mjs";
16
16
  import { hasHomebrew, ensureHomebrewFor } from "./config.mjs";
17
- import { commandExists } from "./exec.mjs";
17
+ import { commandExists, runCommand } from "./exec.mjs";
18
18
  import { pc, renderCard, renderRows } from "./ui.mjs";
19
19
 
20
20
  const execFileAsync = promisify(execFile);
@@ -138,7 +138,6 @@ export async function offerManagedOmlxUpdate(prompt, { fetchImpl = globalThis.fe
138
138
  if (!shouldUpdate) return false;
139
139
 
140
140
  try {
141
- const { runCommand } = await import("./exec.mjs");
142
141
  console.log(pc.dim("Updating oMLX via Homebrew..."));
143
142
  await runCommand("brew", ["update"], { label: "brew update" });
144
143
  await runCommand("brew", ["upgrade", "omlx"], { label: "brew upgrade omlx" });
@@ -153,6 +152,34 @@ export async function offerManagedOmlxUpdate(prompt, { fetchImpl = globalThis.fe
153
152
 
154
153
  // ── Installation ───────────────────────────────────────────────────────────
155
154
 
155
+ /**
156
+ * Offer to restart oMLX so it picks up new or deleted models.
157
+ * @param {object} prompt - UI prompt interface (yesNo)
158
+ * @param {string} [reason] - why we're restarting (e.g. "to load the new model")
159
+ * @returns {Promise<boolean>} true if oMLX was restarted
160
+ */
161
+ export async function offerOmlxRestart(prompt, reason = "to update its model list") {
162
+ const bin = await findOmlx();
163
+ if (!bin) {
164
+ console.log(pc.dim("Restart oMLX manually: omlx restart"));
165
+ return false;
166
+ }
167
+ const shouldRestart = await prompt.yesNo(`Restart oMLX ${reason}?`, true);
168
+ if (!shouldRestart) {
169
+ console.log(pc.dim("Restart manually later: omlx restart"));
170
+ return false;
171
+ }
172
+ try {
173
+ await execFileAsync(bin, ["restart"], { timeout: 15000 });
174
+ console.log(pc.green("✓ oMLX restarted"));
175
+ return true;
176
+ } catch (err) {
177
+ console.log(pc.red(`✗ Restart failed: ${err.message}`));
178
+ console.log(pc.dim("Restart manually: omlx restart"));
179
+ return false;
180
+ }
181
+ }
182
+
156
183
  /**
157
184
  * Install oMLX. Uses Homebrew if available (automating tap + install).
158
185
  * If Homebrew is not available, prompts to download the DMG from GitHub
@@ -167,7 +194,6 @@ export async function installOmlx(prompt, run) {
167
194
 
168
195
  if (!hasBrew) {
169
196
  if (!(await ensureHomebrewFor(prompt, run || (async (cmd, args, label) => {
170
- const { runCommand } = await import("./exec.mjs");
171
197
  return runCommand(cmd, args, { label });
172
198
  }), "oMLX"))) {
173
199
  console.log(pc.dim("Install oMLX manually:"));
@@ -179,7 +205,6 @@ export async function installOmlx(prompt, run) {
179
205
 
180
206
  // Install oMLX via Homebrew
181
207
  const runner = run || (async (cmd, args, label) => {
182
- const { runCommand } = await import("./exec.mjs");
183
208
  return runCommand(cmd, args, { label });
184
209
  });
185
210
 
package/src/process.mjs CHANGED
@@ -6,6 +6,8 @@ import { basename, join } from "node:path";
6
6
  import { LOG_DIR } from "./config.mjs";
7
7
  import { writeState, readState, profileDir } from "./profiles.mjs";
8
8
  import { backendFor, backendBinaryFor } from "./backends.mjs";
9
+ import { computeFlags } from "./autodetect.mjs";
10
+ import { findOmlx } from "./omlx-runtime.mjs";
9
11
  import { pc } from "./ui.mjs";
10
12
 
11
13
  const execFileAsync = promisify(execFile);
@@ -23,7 +25,6 @@ export async function computeServerCommand(profile) {
23
25
  if (!binary) throw new Error("Server binary not found. Run offgrid-ai interactively to install.");
24
26
 
25
27
  // llama-cpp
26
- const { computeFlags } = await import("./autodetect.mjs");
27
28
  const result = computeFlags(
28
29
  profile.capabilities ?? {},
29
30
  profile.modelPath,
@@ -129,14 +130,11 @@ async function startManagedServer(profile, backend) {
129
130
  // Try to start the managed server via CLI
130
131
  if (backend.id === "omlx") {
131
132
  try {
132
- const { execFile } = await import("node:child_process");
133
- const { promisify } = await import("node:util");
134
- const { findOmlx } = await import("./omlx-runtime.mjs");
135
133
  const omlxBin = await findOmlx();
136
134
  if (!omlxBin) {
137
135
  throw new Error(`${backend.label} is not installed. Run offgrid-ai to install it, or install manually: brew tap jundot/omlx && brew install omlx`);
138
136
  }
139
- await promisify(execFile)(omlxBin, ["start"], { timeout: 10000 });
137
+ await execFileAsync(omlxBin, ["start"], { timeout: 10000 });
140
138
  } catch (err) {
141
139
  if (err.message.includes("not installed")) throw err;
142
140
  throw new Error(`${backend.label} could not be auto-started: ${err.message}. Run \`omlx start\` manually.`, { cause: err });
package/src/profiles.mjs CHANGED
@@ -3,6 +3,7 @@ import { mkdir, readdir, rm, unlink, writeFile, readFile } from "node:fs/promise
3
3
  import { join } from "node:path";
4
4
  import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
5
5
  import { backendFor, baseUrlForFlags, defaultFlagsForBackend, BACKENDS } from "./backends.mjs";
6
+ import { detectCapabilities } from "./autodetect.mjs";
6
7
  import { computeFlags } from "./autodetect.mjs";
7
8
  import { readJson, writeJson } from "./json.mjs";
8
9
 
@@ -130,7 +131,6 @@ export function normalizeProfile(profile) {
130
131
  // ── Auto-create profile from a discovered model ────────────────────────────
131
132
 
132
133
  export async function createProfileFromModel(model, backendId, drafterPath) {
133
- const { detectCapabilities } = await import("./autodetect.mjs");
134
134
  const caps = detectCapabilities(model.path, model.mmprojPath);
135
135
  // If a drafter is provided, this model supports MTP regardless of filename
136
136
  const hasMtp = caps.mtp || Boolean(drafterPath);
package/src/runtime.mjs CHANGED
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
- import { basename, join } from "node:path";
6
+ import { join } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { MANAGED_LLAMA_SERVER, RUNTIME_DIR } from "./config.mjs";
9
9
  import { compareVersions } from "./updates.mjs";
@@ -137,5 +137,5 @@ function verifyDigest(bytes, digest) {
137
137
  if (!digest?.startsWith("sha256:")) return;
138
138
  const expected = digest.slice("sha256:".length);
139
139
  const actual = createHash("sha256").update(bytes).digest("hex");
140
- if (actual !== expected) throw new Error(`${basename("llama.cpp")}: checksum mismatch`);
140
+ if (actual !== expected) throw new Error("llama.cpp: checksum mismatch");
141
141
  }
package/src/ui.mjs CHANGED
@@ -237,6 +237,8 @@ export function createPrompt() {
237
237
 
238
238
  export async function modelSelect(label, groups, { defaultKey, pageSize = 20 } = {}) {
239
239
  const choices = [];
240
+ // Blank line after the prompt message for visual separation
241
+ choices.push(new Separator(" "));
240
242
  for (let i = 0; i < groups.length; i++) {
241
243
  const group = groups[i];
242
244
  // Add blank line before each group (except the first)