offgrid-ai 0.3.29 → 0.3.31

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
@@ -19,7 +19,7 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
19
19
 
20
20
  You run `offgrid-ai`. It finds your local models, auto-configures everything, starts the server, and launches Pi. Zero configuration. No parameter tuning. No presets.
21
21
 
22
- **First run** walks you through installing anything missing (Homebrew, llama-server). After that, just run it.
22
+ **First run** walks you through installing anything missing. For GGUF models, offgrid-ai installs a managed `llama.cpp` runtime under `~/.offgrid-ai/runtime`; Homebrew is only used if you choose Homebrew-installed apps like LM Studio, Ollama, or oMLX.
23
23
 
24
24
  ```bash
25
25
  offgrid-ai # pick a model and run it
@@ -49,7 +49,7 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
49
49
 
50
50
  ## How it works
51
51
 
52
- 1. **Auto-detect everything.** Scans for GGUF models in LM Studio, HuggingFace, and Ollama directories. Reads model metadata (quantization, context size, vision, thinking mode) directly from the GGUF file. No presets, no manual configuration.
52
+ 1. **Auto-detect everything.** Scans for GGUF models in LM Studio and Hugging Face cache directories, and checks managed backends like Ollama/oMLX through their local APIs. Reads model metadata (quantization, context size, vision, thinking mode) directly from GGUF files. No presets, no manual configuration.
53
53
 
54
54
  2. **One command to run.** `offgrid-ai` → pick a model → confirm context/KV memory settings on first setup → it starts llama-server, syncs Pi config, and launches Pi.
55
55
 
@@ -60,8 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
60
60
  | Backend | Type | Auto-detected |
61
61
  |---|---|---|
62
62
  | **LM Studio** | Visual model browser + CLI (`lms`) | ✓ models in `~/.lmstudio/models/` |
63
- | **llama.cpp** | Local server | ✓ GGUF models in `~/.lmstudio/models/` |
64
- | **llama.cpp MTP** | Local server (speculative decoding) | ✓ MTP detected from model metadata |
63
+ | **llama.cpp** | Managed local runtime | ✓ GGUF models in `~/.lmstudio/models/` and Hugging Face cache |
64
+ | **llama.cpp MTP** | Managed local runtime (speculative decoding) | ✓ MTP detected from model metadata |
65
65
  | **Ollama** | Managed server | ✓ via `localhost:11434` |
66
66
  | **oMLX** | Managed server | ✓ via `127.0.0.1:8000` |
67
67
 
@@ -69,15 +69,15 @@ curl -fsSL https://raw.githubusercontent.com/eeshansrivastava89/offgrid-ai/main/
69
69
 
70
70
  When you run `offgrid-ai` for the first time on a fresh machine:
71
71
 
72
- 1. **Homebrew** — Required. Offered to install if missing.
73
- 2. **llama-server** — Required for GGUF models. Offered to install via Homebrew.
72
+ 1. **llama.cpp runtime** — Required for GGUF models. Offered as an offgrid-ai managed runtime from official `llama.cpp` release binaries.
73
+ 2. **Pi** — Required to chat from the Pi coding agent. Offered to install via npm if missing.
74
74
  3. **Model backend** — At least one is needed (LM Studio recommended):
75
75
  - **LM Studio** — visual model browser + `lms` CLI, download models with `lms get qwen/qwen3.5-9b`
76
76
  - **Ollama** — models download on demand with `ollama pull`
77
77
  - **oMLX** — Apple Silicon optimized
78
78
  4. **Models** — If no models found, tells you where to get them.
79
79
 
80
- Subsequent runs skip everything that's already installed. When a GGUF model is set up for the first time, offgrid-ai asks only for the memory-impacting choices: context window and KV cache precision. Sampling defaults are shown but not forced into a tuning wizard.
80
+ Homebrew is optional and only prompted when you choose a Homebrew-based backend install. Subsequent runs skip everything that's already installed. When a GGUF model is set up for the first time, offgrid-ai asks only for the memory-impacting choices: context window and KV cache precision. Sampling defaults are shown but not forced into a tuning wizard.
81
81
 
82
82
  ## Data directory
83
83
 
@@ -91,6 +91,7 @@ Subsequent runs skip everything that's already installed. When a GGUF model is s
91
91
  notes.md # scratch notes
92
92
  logs/
93
93
  run/ # PID state files
94
+ runtime/ # managed llama.cpp binaries
94
95
  ```
95
96
 
96
97
  ## Benchmark (coming soon)
package/install.sh CHANGED
@@ -205,7 +205,7 @@ printf "${BOLD}${GREEN} offgrid-ai ${INSTALLED_VERSION:+v${INSTALLED_VERSION} }
205
205
  printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n"
206
206
  echo ""
207
207
  echo " First run will walk you through setting up everything you need"
208
- echo " (llama-server, model backends, Pi)."
208
+ echo " (managed llama.cpp runtime for GGUF models, model backends, Pi)."
209
209
  echo ""
210
210
  if command -v offgrid-ai &>/dev/null; then
211
211
  echo " Run: offgrid-ai"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  import { basename } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readGgufMetadata } from "./gguf.mjs";
4
+ import { defaultFlagsForBackend } from "./backends.mjs";
4
5
 
5
6
  // ── Detect model capabilities from GGUF metadata ──────────────────────────
6
7
 
@@ -51,8 +52,7 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
51
52
  const isLowMem = quant && /[Qq]4[_0]/i.test(quant);
52
53
 
53
54
  const flags = {
54
- host: "127.0.0.1",
55
- port: mtp ? 8081 : 8080,
55
+ ...defaultFlagsForBackend(mtp ? "llama-cpp-mtp" : "llama-cpp"),
56
56
  ctxSize: capabilities.ctxSize,
57
57
  flashAttention: "on",
58
58
  cacheTypeK: isLowMem ? "f16" : "bf16",
package/src/backends.mjs CHANGED
@@ -3,16 +3,30 @@ import { scanGgufModels } from "./scan.mjs";
3
3
 
4
4
  // ── Backend definitions ────────────────────────────────────────────────────
5
5
 
6
+ export const LOCAL_HOST = "127.0.0.1";
7
+ export const LLAMA_CPP_PORT = 8080;
8
+ export const LLAMA_CPP_MTP_PORT = 8081;
9
+ export const OLLAMA_PORT = 11434;
10
+ export const OMLX_PORT = 8000;
11
+
12
+ export function baseUrlFor({ host = LOCAL_HOST, port, path = "/v1" }) {
13
+ return `http://${host}:${port}${path}`;
14
+ }
15
+
16
+ export function baseUrlForFlags(flags) {
17
+ return baseUrlFor({ host: flags.host, port: flags.port });
18
+ }
19
+
6
20
  export const BACKENDS = {
7
21
  "llama-cpp": {
8
22
  id: "llama-cpp",
9
23
  label: "llama.cpp",
10
24
  type: "local-server",
11
25
  providerId: "llama-cpp",
12
- defaultPort: 8080,
13
- defaultBaseUrl: "http://127.0.0.1:8080/v1",
26
+ defaultHost: LOCAL_HOST,
27
+ defaultPort: LLAMA_CPP_PORT,
28
+ defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_PORT }),
14
29
  needsCommandFile: true,
15
- needsModelFile: true,
16
30
  scanModels: () => scanGgufModels(),
17
31
  },
18
32
  "llama-cpp-mtp": {
@@ -20,11 +34,10 @@ export const BACKENDS = {
20
34
  label: "llama.cpp MTP",
21
35
  type: "local-server",
22
36
  providerId: "llama-cpp-mtp",
23
- defaultPort: 8081,
24
- defaultBaseUrl: "http://127.0.0.1:8081/v1",
37
+ defaultHost: LOCAL_HOST,
38
+ defaultPort: LLAMA_CPP_MTP_PORT,
39
+ defaultBaseUrl: baseUrlFor({ port: LLAMA_CPP_MTP_PORT }),
25
40
  needsCommandFile: true,
26
- needsModelFile: true,
27
- extraArgs: ["--spec-type", "draft-mtp", "--spec-draft-n-max", "2"],
28
41
  scanModels: () => scanGgufModels(),
29
42
  },
30
43
  "ollama": {
@@ -32,10 +45,11 @@ export const BACKENDS = {
32
45
  label: "Ollama",
33
46
  type: "managed-server",
34
47
  providerId: "ollama",
35
- defaultPort: 11434,
36
- defaultBaseUrl: "http://localhost:11434/v1",
48
+ defaultHost: "localhost",
49
+ defaultPort: OLLAMA_PORT,
50
+ defaultBaseUrl: baseUrlFor({ host: "localhost", port: OLLAMA_PORT }),
51
+ apiBaseUrl: baseUrlFor({ host: "localhost", port: OLLAMA_PORT, path: "" }),
37
52
  needsCommandFile: false,
38
- needsModelFile: false,
39
53
  scanModels: () => scanOllamaModels(),
40
54
  },
41
55
  "omlx": {
@@ -43,10 +57,11 @@ export const BACKENDS = {
43
57
  label: "oMLX",
44
58
  type: "managed-server",
45
59
  providerId: "omlx",
46
- defaultPort: 8000,
47
- defaultBaseUrl: "http://127.0.0.1:8000/v1",
60
+ defaultHost: LOCAL_HOST,
61
+ defaultPort: OMLX_PORT,
62
+ defaultBaseUrl: baseUrlFor({ port: OMLX_PORT }),
63
+ apiBaseUrl: baseUrlFor({ port: OMLX_PORT, path: "" }),
48
64
  needsCommandFile: false,
49
- needsModelFile: false,
50
65
  scanModels: () => scanOmlxModels(),
51
66
  },
52
67
  };
@@ -64,11 +79,16 @@ export async function backendBinaryFor(backendId) {
64
79
  return discovered; // null means "not found — trigger onboarding"
65
80
  }
66
81
 
82
+ export function defaultFlagsForBackend(backendId) {
83
+ const backend = backendFor(backendId);
84
+ return { host: backend.defaultHost ?? LOCAL_HOST, port: backend.defaultPort };
85
+ }
86
+
67
87
  // ── Ollama model discovery ──────────────────────────────────────────────
68
88
 
69
89
  async function scanOllamaModels() {
70
90
  try {
71
- const response = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(3000) });
91
+ const response = await fetch(`${BACKENDS.ollama.apiBaseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
72
92
  if (!response.ok) return [];
73
93
  const body = await response.json();
74
94
  if (!Array.isArray(body?.models)) return [];
@@ -93,7 +113,7 @@ async function scanOllamaModels() {
93
113
 
94
114
  async function scanOmlxModels() {
95
115
  try {
96
- const response = await fetch("http://127.0.0.1:8000/v1/models", { signal: AbortSignal.timeout(3000) });
116
+ const response = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(3000) });
97
117
  if (!response.ok) return [];
98
118
  const body = await response.json();
99
119
  if (!Array.isArray(body?.data)) return [];
package/src/cli.mjs CHANGED
@@ -2,10 +2,10 @@ import { totalmem } from "node:os";
2
2
  import { existsSync, statSync, rmSync } from "node:fs";
3
3
  import { ensureDirs, findLlamaServer, hasHomebrew, DATA_DIR } from "./config.mjs";
4
4
  import { scanGgufModels } from "./scan.mjs";
5
- import { createProfileFromModel, normalizeProfile } from "./profiles.mjs";
6
- import { readProfile, saveProfile, deleteProfile, loadProfiles } from "./profiles.mjs";
5
+ import { createProfileFromModel, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
6
+ import { readProfile, saveProfile, deleteProfile, loadProfiles, readCommandArgv } from "./profiles.mjs";
7
7
  import { backendFor, BACKENDS } from "./backends.mjs";
8
- import { startServer, stopProfile, waitForReady, serverReady, isProfileRunning, profileRuntimeStatus } from "./process.mjs";
8
+ import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile, isProfileRunning, profileRuntimeStatus } from "./process.mjs";
9
9
  import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from "./harness-pi.mjs";
10
10
  import { tailFriendly } from "./logs.mjs";
11
11
  import { estimateMemory } from "./estimate.mjs";
@@ -21,7 +21,7 @@ import { offerManagedLlamaRuntimeUpdate } from "./runtime.mjs";
21
21
 
22
22
  async function offerUpdate(argv) {
23
23
  const invocation = detectInvocation();
24
- const update = await checkForUpdate({ force: invocation === "npx" });
24
+ const update = await checkForUpdate({ force: true });
25
25
  if (!update) return false;
26
26
 
27
27
  const plan = updateCommand(invocation, argv);
@@ -83,14 +83,12 @@ export async function mainFlow() {
83
83
  const profiles = await loadProfiles();
84
84
  const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
85
85
  const hasAnyModels = ggufModels.length > 0 || managedModels.some((m) => m.models.length > 0);
86
- const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
87
86
 
88
87
  // 2. Check mandatory deps — if anything essential is missing, re-offer onboarding
89
88
  const piInstalled = await hasPi();
90
- const brewInstalled = await hasHomebrew();
89
+ const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
91
90
  const missingDeps = [];
92
- if (!brewInstalled) missingDeps.push("Homebrew");
93
- if (!llamaBinary) missingDeps.push("llama-server");
91
+ if (needsLlama && !llamaBinary) missingDeps.push("llama-server");
94
92
  if (!piInstalled) missingDeps.push("Pi");
95
93
  if (missingDeps.length > 0) {
96
94
  if (!process.stdin.isTTY) {
@@ -109,18 +107,6 @@ export async function mainFlow() {
109
107
  return await onboardFlow();
110
108
  }
111
109
 
112
- // 3. Has models but no llama-server (managed backends only)
113
- if (!llamaBinary && ggufModels.length > 0) {
114
- // They have GGUF files but can't run them — tell them about llama-server
115
- console.log(pc.yellow(`${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"} found, but llama-server is not installed.`));
116
- console.log(pc.dim("Install it with: brew install llama.cpp"));
117
- console.log(pc.dim("Or use Ollama/oMLX for managed model backends."));
118
- if (totalManaged === 0 && profiles.length === 0) {
119
- return; // Nothing to do without llama-server
120
- }
121
- // Fall through — they can still use managed backends
122
- }
123
-
124
110
  // 4. No models found at all (but backends may exist)
125
111
  if (!hasAnyModels && profiles.length === 0) {
126
112
  if (!process.stdin.isTTY) {
@@ -213,10 +199,10 @@ async function modelCommandCenter(catalog) {
213
199
 
214
200
  async function runCommand(argv) {
215
201
  await ensureDirs();
216
- const { positional } = parseOptions(argv);
202
+ const { positional, options } = parseOptions(argv);
217
203
  if (!positional[0]) return await mainFlow();
218
204
  const profile = await readProfile(positional[0]);
219
- return await runProfile(profile);
205
+ return await runProfile(profile, options);
220
206
  }
221
207
 
222
208
  async function loadModelCatalog() {
@@ -424,7 +410,8 @@ async function printProfileDetails(profile) {
424
410
  ]), { columns: 110 }));
425
411
 
426
412
  if (!isManaged && profile.commandArgv) {
427
- console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand(profile)), { columns: 120 }));
413
+ const commandArgv = await readCommandArgv(profile);
414
+ console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand({ ...profile, commandArgv })), { columns: 120 }));
428
415
  }
429
416
  }
430
417
 
@@ -495,7 +482,7 @@ function removeCommandOption(argv, flag) {
495
482
 
496
483
  function createManagedProfile(model, backendId) {
497
484
  return normalizeProfile({
498
- id: model.id.replace(/[^a-z0-9._-]+/gi, "-").toLowerCase(),
485
+ id: `${backendId}-${sanitizeProfileId(model.id)}`,
499
486
  label: model.label,
500
487
  backend: backendId,
501
488
  modelAlias: model.aliasSuggestion,
@@ -529,6 +516,10 @@ async function runProfile(profile, options = {}) {
529
516
  } else {
530
517
  const ready = await serverReady(profile.baseUrl);
531
518
  if (ready) {
519
+ const match = await serverMatchesProfile(profile);
520
+ if (!match.matches) {
521
+ throw new Error(`A different server is already responding at ${profile.baseUrl}. ${match.reason}. Stop it with offgrid-ai stop --all, or choose a different port.`);
522
+ }
532
523
  console.log(pc.green(`[ready] Reusing server at ${profile.baseUrl}`));
533
524
  } else {
534
525
  console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
@@ -551,7 +542,7 @@ async function runProfile(profile, options = {}) {
551
542
  console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
552
543
  console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
553
544
  const textOnly = textOnlyProfile(profile);
554
- await saveProfile(textOnly);
545
+ await saveProfile(textOnly, { writeCommand: true });
555
546
  return await runProfile(textOnly, { ...options, textOnlyRetry: true });
556
547
  }
557
548
  throw err;
@@ -782,19 +773,32 @@ async function onboardFlow() {
782
773
  console.log(pc.bold("Welcome to offgrid-ai!"));
783
774
  console.log(pc.dim("Let's make sure you have everything you need to run local models.\n"));
784
775
 
785
- // 1. Homebrew
786
- const hasBrew = await hasHomebrew();
787
- if (!hasBrew) {
788
- const install = await prompt.yesNo("Homebrew is required. Install it now?", true);
776
+ // 1. llama.cpp runtime for local GGUF models
777
+ let llamaBinary = await findLlamaServer();
778
+ if (!llamaBinary) {
779
+ console.log(renderSection("llama.cpp runtime", renderRows([
780
+ ["Status", pc.yellow("not installed")],
781
+ ["Used for", "local GGUF models"],
782
+ ["Install", "managed by offgrid-ai under ~/.offgrid-ai/runtime"],
783
+ ]), { formatBorder: pc.cyan }));
784
+ await offerManagedLlamaRuntimeUpdate(prompt);
785
+ llamaBinary = await findLlamaServer();
786
+ if (!llamaBinary) {
787
+ console.log(pc.yellow("Skipping llama.cpp for now. You can still use Ollama/oMLX, or run offgrid-ai again to install the managed runtime."));
788
+ }
789
+ }
790
+ if (llamaBinary) console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
791
+
792
+ const ensureHomebrewFor = async (label) => {
793
+ if (await hasHomebrew()) return true;
794
+ const install = await prompt.yesNo(`Homebrew is needed to install ${label}. Install Homebrew now?`, true);
789
795
  if (!install) {
790
- console.log(pc.red("offgrid-ai needs Homebrew to install dependencies."));
791
- console.log(pc.dim("Install it from https://brew.sh, then run offgrid-ai again."));
792
- return;
796
+ console.log(pc.dim(`Install ${label} manually, or install Homebrew from https://brew.sh and run offgrid-ai again.`));
797
+ return false;
793
798
  }
794
799
  console.log(pc.cyan("Installing Homebrew..."));
795
800
  try {
796
801
  await run("/bin/bash", ["-c", "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""], "Homebrew");
797
- // Add brew to PATH for this session
798
802
  const brewPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
799
803
  for (const p of brewPaths) {
800
804
  if (existsSync(p)) {
@@ -803,43 +807,19 @@ async function onboardFlow() {
803
807
  }
804
808
  }
805
809
  } catch {
806
- console.log(pc.red(`✗ Homebrew installation failed.`));
810
+ console.log(pc.red("✗ Homebrew installation failed."));
807
811
  console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
808
- return;
812
+ return false;
809
813
  }
810
814
  if (!(await hasHomebrew())) {
811
815
  console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
812
- return;
816
+ return false;
813
817
  }
814
- }
815
- console.log(pc.green("✓ Homebrew found"));
816
-
817
- // 2. llama-server
818
- let llamaBinary = await findLlamaServer();
819
- if (!llamaBinary) {
820
- const install = await prompt.yesNo("llama-server is required to run local models. Install via Homebrew?", true);
821
- if (!install) {
822
- console.log(pc.red("offgrid-ai needs llama-server to run local models."));
823
- console.log(pc.dim("Install it manually: brew install llama.cpp"));
824
- return;
825
- }
826
- console.log(pc.cyan("Installing llama.cpp..."));
827
- try {
828
- await run("brew", ["install", "llama.cpp"], "llama.cpp");
829
- llamaBinary = await findLlamaServer();
830
- } catch {
831
- console.log(pc.red("✗ Failed to install llama.cpp."));
832
- console.log(pc.dim("Install it manually: brew install llama.cpp"));
833
- return;
834
- }
835
- if (!llamaBinary) {
836
- console.log(pc.yellow("llama.cpp installed but llama-server not found. You may need to restart your terminal."));
837
- return;
838
- }
839
- }
840
- console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
818
+ console.log(pc.green("✓ Homebrew found"));
819
+ return true;
820
+ };
841
821
 
842
- // 3. Pi coding agent
822
+ // 2. Pi coding agent
843
823
  const piInstalled = await hasPi();
844
824
  if (!piInstalled) {
845
825
  const install = await prompt.yesNo("Pi coding agent is required to chat with models. Install via npm?", true);
@@ -873,6 +853,7 @@ async function onboardFlow() {
873
853
  // They already have models — show what was found
874
854
  if (ggufModels.length > 0) {
875
855
  console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
856
+ if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
876
857
  }
877
858
  for (const { backendId, models } of managedModels) {
878
859
  if (models.length > 0) {
@@ -895,6 +876,7 @@ async function onboardFlow() {
895
876
  const model = recommendedModel();
896
877
 
897
878
  if (backendChoice === "lmstudio") {
879
+ if (!(await ensureHomebrewFor("LM Studio"))) return;
898
880
  console.log(pc.cyan("Installing LM Studio via Homebrew..."));
899
881
  try {
900
882
  await run("brew", ["install", "--cask", "lm-studio"], "LM Studio");
@@ -907,6 +889,7 @@ async function onboardFlow() {
907
889
  console.log(pc.dim("Download it manually from https://lmstudio.ai"));
908
890
  }
909
891
  } else if (backendChoice === "ollama") {
892
+ if (!(await ensureHomebrewFor("Ollama"))) return;
910
893
  console.log(pc.cyan("Installing Ollama via Homebrew..."));
911
894
  try {
912
895
  await run("brew", ["install", "ollama"], "Ollama");
@@ -920,6 +903,7 @@ async function onboardFlow() {
920
903
  console.log(pc.dim("Install it manually from https://ollama.com"));
921
904
  }
922
905
  } else if (backendChoice === "omlx") {
906
+ if (!(await ensureHomebrewFor("oMLX"))) return;
923
907
  console.log(pc.cyan("Installing oMLX via Homebrew..."));
924
908
  try {
925
909
  await run("brew", ["tap", "jundot/omlx", "https://github.com/jundot/omlx"], "oMLX tap");
@@ -934,6 +918,7 @@ async function onboardFlow() {
934
918
  console.log(pc.dim("Install manually: brew tap jundot/omlx && brew install omlx"));
935
919
  }
936
920
  } else if (backendChoice === "all") {
921
+ if (!(await ensureHomebrewFor("model backends"))) return;
937
922
  let installed = [];
938
923
  // LM Studio
939
924
  console.log(pc.cyan("Installing LM Studio via Homebrew..."));
@@ -1132,7 +1117,7 @@ async function printVersion() {
1132
1117
  const version = currentPackageVersion();
1133
1118
  console.log(`offgrid-ai v${version}`);
1134
1119
  const invocation = detectInvocation();
1135
- const update = await checkForUpdate({ force: invocation === "npx" });
1120
+ const update = await checkForUpdate({ force: true });
1136
1121
  if (update) {
1137
1122
  const plan = updateCommand(invocation, ["version"]);
1138
1123
  console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
package/src/config.mjs CHANGED
@@ -76,17 +76,22 @@ export async function findLlamaServer() {
76
76
  return process.env.LLAMA_SERVER_BINARY;
77
77
  }
78
78
 
79
- // 2. offgrid-ai managed runtime
79
+ // 2. User config override
80
+ const config = await loadConfig();
81
+ const configured = config.binaryOverrides?.llamaServer ?? config.binaryOverrides?.["llama-server"];
82
+ if (configured && existsSync(configured)) return configured;
83
+
84
+ // 3. offgrid-ai managed runtime
80
85
  if (existsSync(MANAGED_LLAMA_SERVER)) return MANAGED_LLAMA_SERVER;
81
86
 
82
- // 3. which/where
87
+ // 4. PATH
83
88
  try {
84
89
  const { stdout } = await execFileAsync("which", ["llama-server"]);
85
90
  const path = stdout.trim();
86
91
  if (path && existsSync(path)) return path;
87
92
  } catch { /* not on PATH */ }
88
93
 
89
- // 4. Homebrew
94
+ // 5. Homebrew fallback
90
95
  try {
91
96
  const { stdout } = await execFileAsync("brew", ["--prefix", "llama.cpp"]);
92
97
  const prefix = stdout.trim();
package/src/gguf.mjs CHANGED
@@ -3,21 +3,30 @@ import { openSync, readSync, closeSync, statSync } from "node:fs";
3
3
  export function readGgufMetadata(path) {
4
4
  const buffer = readFilePrefix(path, 64 * 1024 * 1024);
5
5
  let offset = 0;
6
- if (buffer.toString("utf8", 0, 4) !== "GGUF") return {};
6
+ const meta = {};
7
+ if (buffer.length < 24 || buffer.toString("utf8", 0, 4) !== "GGUF") return meta;
7
8
  offset += 4;
8
9
  offset += 4; // version
9
10
  offset += 8; // tensor count
11
+ if (offset + 8 > buffer.length) return meta;
10
12
  const kvCount = Number(buffer.readBigUInt64LE(offset));
11
13
  offset += 8;
12
- const meta = {};
13
14
  for (let i = 0; i < kvCount; i++) {
15
+ if (offset + 8 > buffer.length) return meta;
14
16
  const keyLen = Number(buffer.readBigUInt64LE(offset));
15
17
  offset += 8;
18
+ if (offset + keyLen + 4 > buffer.length) return meta;
16
19
  const key = buffer.toString("utf8", offset, offset + keyLen);
17
20
  offset += keyLen;
18
21
  const type = buffer.readUInt32LE(offset);
19
22
  offset += 4;
20
- const read = readValue(buffer, offset, type);
23
+ let read;
24
+ try {
25
+ read = readValue(buffer, offset, type);
26
+ } catch {
27
+ return meta;
28
+ }
29
+ if (read.offset > buffer.length) return meta;
21
30
  offset = read.offset;
22
31
  meta[key] = read.value;
23
32
  }
package/src/process.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { openSync } from "node:fs";
3
+ import { closeSync, openSync } from "node:fs";
4
4
  import { readFile, writeFile } from "node:fs/promises";
5
- import { join } from "node:path";
5
+ import { basename, join } from "node:path";
6
6
  import { LOG_DIR } from "./config.mjs";
7
- import { writeState, readState } from "./profiles.mjs";
7
+ import { writeState, readState, readCommandArgv } from "./profiles.mjs";
8
8
  import { backendFor, backendBinaryFor } from "./backends.mjs";
9
9
 
10
10
  const execFileAsync = promisify(execFile);
@@ -22,22 +22,27 @@ export async function startServer(profile) {
22
22
  async function startLocalServer(profile) {
23
23
  const binary = await backendBinaryFor(profile.backend);
24
24
  if (!binary) {
25
- throw new Error("llama-server not found. Install it via Homebrew: brew install llama.cpp");
25
+ throw new Error("llama-server not found. Install the managed llama.cpp runtime by running offgrid-ai interactively.");
26
26
  }
27
27
 
28
28
  const timestamp = timestampForFile();
29
29
  const rawLogPath = join(LOG_DIR, `${profile.id}-${timestamp}.raw.log`);
30
30
  const friendlyLogPath = join(LOG_DIR, `${profile.id}-${timestamp}.friendly.log`);
31
- const commandJson = profile.commandArgv ?? [];
31
+ const commandArgv = await readCommandArgv(profile);
32
32
 
33
- await writeFile(rawLogPath, `[offgrid-ai] ${new Date().toISOString()}\n[binary] ${binary}\n[argv]\n${commandJson.join(" ")}\n`, "utf8");
33
+ await writeFile(rawLogPath, `[offgrid-ai] ${new Date().toISOString()}\n[binary] ${binary}\n[argv]\n${commandArgv.join(" ")}\n`, "utf8");
34
34
  await writeFile(friendlyLogPath, `[launch] starting llama-server for ${profile.label}\n`, "utf8");
35
35
 
36
36
  // Build argv: binary + command.json args
37
- const argv = [...commandJson];
37
+ const argv = [...commandArgv];
38
38
 
39
39
  const rawFd = openSync(rawLogPath, "a");
40
- const child = spawn(binary, argv, { detached: true, stdio: ["ignore", rawFd, rawFd] });
40
+ let child;
41
+ try {
42
+ child = spawn(binary, argv, { detached: true, stdio: ["ignore", rawFd, rawFd] });
43
+ } finally {
44
+ closeSync(rawFd);
45
+ }
41
46
  child.unref();
42
47
 
43
48
  const state = {
@@ -137,6 +142,27 @@ export async function serverReady(baseUrl) {
137
142
  }
138
143
  }
139
144
 
145
+ export async function serverMatchesProfile(profile) {
146
+ const state = await readState(profile.id);
147
+ if (state?.pid && pidAlive(state.pid) && state.baseUrl === profile.baseUrl) {
148
+ return { matches: true, reason: "tracked offgrid-ai server" };
149
+ }
150
+
151
+ const ids = await serverModelIds(profile.baseUrl);
152
+ const expected = expectedModelIds(profile);
153
+ const normalizedIds = new Set(ids.map((id) => id.toLowerCase()));
154
+ if (ids.length > 0 && expected.some((id) => normalizedIds.has(id.toLowerCase()))) {
155
+ return { matches: true, reason: `server reports ${ids.join(", ")}` };
156
+ }
157
+
158
+ return {
159
+ matches: false,
160
+ reason: ids.length > 0
161
+ ? `server reports ${ids.join(", ")}; expected ${expected.join(" or ")}`
162
+ : "server is untracked and did not report a recognizable model id",
163
+ };
164
+ }
165
+
140
166
  export async function waitForReady(profile, pid, rawLogPath) {
141
167
  const backend = backendFor(profile.backend);
142
168
  if (backend.type === "managed-server") return;
@@ -153,6 +179,32 @@ export async function waitForReady(profile, pid, rawLogPath) {
153
179
 
154
180
  // ── Internals ──────────────────────────────────────────────────────────────
155
181
 
182
+ async function serverModelIds(baseUrl) {
183
+ try {
184
+ const response = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, { signal: AbortSignal.timeout(1000) });
185
+ if (!response.ok) return [];
186
+ const body = await response.json();
187
+ return (Array.isArray(body?.data) ? body.data : [])
188
+ .map((model) => String(model?.id ?? "").trim())
189
+ .filter(Boolean);
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
194
+
195
+ function expectedModelIds(profile) {
196
+ const fileName = profile.modelPath ? basename(profile.modelPath) : null;
197
+ return [
198
+ profile.modelAlias,
199
+ profile.label,
200
+ profile.ollamaModel,
201
+ profile.omlxModel,
202
+ profile.modelPath,
203
+ fileName,
204
+ fileName ? fileName.replace(/\.gguf$/iu, "") : null,
205
+ ].filter(Boolean).map(String);
206
+ }
207
+
156
208
  function pidAlive(pid) {
157
209
  try { process.kill(pid, 0); return true; }
158
210
  catch { return false; }
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { estimateMemory } from "./estimate.mjs";
4
4
  import { findLlamaServer } from "./config.mjs";
5
+ import { baseUrlForFlags, LLAMA_CPP_PORT, LLAMA_CPP_MTP_PORT } from "./backends.mjs";
5
6
  import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
6
7
 
7
8
  const execFileAsync = promisify(execFile);
@@ -44,7 +45,7 @@ export async function configureLocalProfile(prompt, profile) {
44
45
  if (caps.mtp) {
45
46
  console.log(renderSection("MTP detected", renderRows([
46
47
  ["Backend", "llama.cpp MTP"],
47
- ["Port", "8081"],
48
+ ["Port", String(LLAMA_CPP_MTP_PORT)],
48
49
  ["Flags", "--spec-type draft-mtp --spec-draft-n-max 2"],
49
50
  ])));
50
51
  const useMtp = await prompt.yesNo("Use MTP speculative decoding flags?", true);
@@ -111,14 +112,14 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
111
112
  }
112
113
 
113
114
  function applyMtpDefaults(profile) {
114
- const flags = { ...profile.flags, port: 8081 };
115
+ const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
115
116
  return applyProfileFlags({ ...profile, backend: "llama-cpp-mtp", providerId: "llama-cpp-mtp" }, flags, {
116
117
  values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 2 },
117
118
  });
118
119
  }
119
120
 
120
121
  function removeMtpDefaults(profile) {
121
- const flags = { ...profile.flags, port: 8080 };
122
+ const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
122
123
  return applyProfileFlags({ ...profile, backend: "llama-cpp", providerId: "llama-cpp" }, flags, {
123
124
  remove: ["--spec-type", "--spec-draft-n-max"],
124
125
  });
@@ -156,7 +157,7 @@ function applyProfileFlags(profile, flags, edits = {}) {
156
157
  const next = {
157
158
  ...profile,
158
159
  flags,
159
- baseUrl: `http://${flags.host}:${flags.port}/v1`,
160
+ baseUrl: baseUrlForFlags(flags),
160
161
  harnesses: {
161
162
  ...(profile.harnesses ?? {}),
162
163
  pi: { ...(profile.harnesses?.pi ?? {}), enabled: true, model: `${profile.providerId ?? profile.backend}/${profile.modelAlias ?? profile.id}` },
package/src/profiles.mjs CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { mkdir, readdir, rm, unlink, writeFile, readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { PROFILE_DIR, RUN_DIR, LOG_DIR } from "./config.mjs";
5
- import { backendFor } from "./backends.mjs";
5
+ import { backendFor, baseUrlForFlags, defaultFlagsForBackend } from "./backends.mjs";
6
6
  import { computeFlags } from "./autodetect.mjs";
7
7
  import { readJson, writeJson } from "./json.mjs";
8
8
 
@@ -28,10 +28,6 @@ export function statePath(id) {
28
28
  return join(RUN_DIR, `${sanitizeProfileId(id)}.state.json`);
29
29
  }
30
30
 
31
- export function profileExists(id) {
32
- return existsSync(profileJsonPath(id));
33
- }
34
-
35
31
  export function sanitizeProfileId(value) {
36
32
  return String(value).trim().toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-|-$/gu, "") || "profile";
37
33
  }
@@ -57,7 +53,7 @@ export async function readProfile(id) {
57
53
  return JSON.parse(await readFile(path, "utf8"));
58
54
  }
59
55
 
60
- export async function saveProfile(profile) {
56
+ export async function saveProfile(profile, options = {}) {
61
57
  const id = sanitizeProfileId(profile.id);
62
58
  const dir = profileDir(id);
63
59
  await mkdir(dir, { recursive: true });
@@ -75,8 +71,8 @@ export async function saveProfile(profile) {
75
71
  const backend = backendFor(saved.backend);
76
72
  if (backend.needsCommandFile) {
77
73
  const cmdPath = commandJsonPath(id);
78
- if (!existsSync(cmdPath)) {
79
- await writeJson(cmdPath, { argv: saved.commandArgv ?? saved.flags ?? [] });
74
+ if (options.writeCommand || !existsSync(cmdPath)) {
75
+ await writeJson(cmdPath, { argv: saved.commandArgv ?? [] });
80
76
  }
81
77
  }
82
78
 
@@ -116,12 +112,11 @@ export async function deleteProfile(id, options = {}) {
116
112
  export function normalizeProfile(profile) {
117
113
  const backend = backendFor(profile.backend);
118
114
  const flags = {
119
- host: "127.0.0.1",
120
- port: backend.defaultPort,
115
+ ...defaultFlagsForBackend(backend.id),
121
116
  ...profile.flags,
122
117
  };
123
118
  if (!profile.baseUrl) {
124
- profile.baseUrl = `http://${flags.host}:${flags.port}/v1`;
119
+ profile.baseUrl = baseUrlForFlags(flags);
125
120
  }
126
121
 
127
122
  return {
@@ -175,6 +170,11 @@ function summarizeCapabilities(caps) {
175
170
 
176
171
  // ── State files (for running servers) ──────────────────────────────────────
177
172
 
173
+ export async function readCommandArgv(profile) {
174
+ const command = await readJson(commandJsonPath(profile.id), null);
175
+ return Array.isArray(command?.argv) ? command.argv.map(String) : (profile.commandArgv ?? []).map(String);
176
+ }
177
+
178
178
  export async function readState(id) {
179
179
  return readJson(statePath(id), null);
180
180
  }
package/src/runtime.mjs CHANGED
@@ -29,7 +29,7 @@ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globa
29
29
  const shouldInstall = await prompt.yesNo(installed ? "Update llama.cpp runtime?" : "Install llama.cpp runtime?", true);
30
30
  if (!shouldInstall) return false;
31
31
 
32
- await installLlamaRelease(latest);
32
+ await installLlamaRelease(latest, { fetchImpl });
33
33
  return true;
34
34
  }
35
35
 
@@ -55,7 +55,7 @@ export async function installedRuntime() {
55
55
  }
56
56
  }
57
57
 
58
- export async function installLlamaRelease(release) {
58
+ export async function installLlamaRelease(release, { fetchImpl = globalThis.fetch } = {}) {
59
59
  const tmp = await mkdtemp(join(tmpdir(), "offgrid-llama-"));
60
60
  const archive = join(tmp, release.asset.name);
61
61
  const releaseDir = join(RUNTIME_DIR, "llama.cpp", "releases", release.tag);
@@ -63,7 +63,7 @@ export async function installLlamaRelease(release) {
63
63
 
64
64
  try {
65
65
  console.log(pc.dim(`Downloading ${release.asset.name}...`));
66
- const response = await fetch(release.asset.url);
66
+ const response = await fetchImpl(release.asset.url);
67
67
  if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`);
68
68
  const bytes = Buffer.from(await response.arrayBuffer());
69
69
  verifyDigest(bytes, release.asset.digest);
package/src/ui.mjs CHANGED
@@ -3,8 +3,6 @@ import pc from "picocolors";
3
3
  import { stripVTControlCharacters } from "node:util";
4
4
 
5
5
  export { pc };
6
- export { pc as colors };
7
-
8
6
  export function formatBytes(bytes) {
9
7
  if (!Number.isFinite(bytes)) return "unknown";
10
8
  const units = ["B", "KB", "MB", "GB", "TB"];