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