offgrid-ai 0.16.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/package.json +3 -11
- package/resources/recommendations.json +8 -8
- package/src/cli.mjs +1 -4
- package/src/commands/main.mjs +20 -1
- package/src/commands/models.mjs +296 -39
- package/src/commands/onboard.mjs +6 -106
- package/src/commands/run.mjs +2 -4
- package/src/commands/status.mjs +1 -0
- package/src/commands/stop.mjs +1 -0
- package/src/config.mjs +16 -1
- package/src/discovery-shared.mjs +2 -3
- package/src/download.mjs +221 -0
- package/src/harness-pi.mjs +2 -3
- package/src/huggingface.mjs +72 -72
- package/src/managed.mjs +1 -6
- package/src/model-name.mjs +2 -2
- package/src/model-presenters.mjs +5 -36
- package/src/model-summary.mjs +2 -2
- package/src/omlx-runtime.mjs +29 -4
- package/src/process.mjs +3 -5
- package/src/profile-setup.mjs +206 -49
- package/src/profiles.mjs +1 -1
- package/src/runtime.mjs +2 -2
- package/src/ui.mjs +10 -8
- package/resources/hf-download.py +0 -79
- package/src/backend-installers.mjs +0 -42
- package/src/benchmark/finalize.mjs +0 -169
- package/src/benchmark/flow.mjs +0 -240
- package/src/benchmark/metrics.mjs +0 -107
- 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
|
@@ -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.18.0",
|
|
4
|
+
"description": "Privacy-first CLI for running local LLMs — discover, configure, run, and chat",
|
|
5
5
|
"author": "Eeshan Srivastava (https://eeshans.com)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
"bin/*.mjs",
|
|
12
12
|
"src/*.mjs",
|
|
13
13
|
"src/commands/*.mjs",
|
|
14
|
-
"src/benchmark/*.mjs",
|
|
15
14
|
"resources/*.py",
|
|
16
15
|
"resources/recommendations.json",
|
|
17
16
|
"install.sh"
|
|
@@ -34,7 +33,7 @@
|
|
|
34
33
|
"start": "node bin/offgrid-ai.mjs",
|
|
35
34
|
"test": "node --test test/*.mjs",
|
|
36
35
|
"test:integration": "OFFGRID_INTEGRATION=1 node --test test/integration/*.mjs",
|
|
37
|
-
"lint": "eslint src/*.mjs src/commands/*.mjs
|
|
36
|
+
"lint": "eslint src/*.mjs src/commands/*.mjs scripts/*.mjs bin/*.mjs",
|
|
38
37
|
"check:privacy": "node scripts/privacy-gate.mjs",
|
|
39
38
|
"release:check": "bash scripts/release-check.sh",
|
|
40
39
|
"release:check:fast": "bash scripts/release-check.sh --skip-install --skip-manual",
|
|
@@ -42,9 +41,6 @@
|
|
|
42
41
|
"pretest": "npm run lint"
|
|
43
42
|
},
|
|
44
43
|
"dependencies": {
|
|
45
|
-
"@earendil-works/pi-agent-core": "^0.80.3",
|
|
46
|
-
"@earendil-works/pi-ai": "^0.80.3",
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
48
44
|
"@inquirer/prompts": "^8.5.2",
|
|
49
45
|
"picocolors": "^1.1.0"
|
|
50
46
|
},
|
|
@@ -61,9 +57,5 @@
|
|
|
61
57
|
"@eslint/js": "^10.0.1",
|
|
62
58
|
"eslint": "^10.4.1",
|
|
63
59
|
"globals": "^17.6.0"
|
|
64
|
-
},
|
|
65
|
-
"allowScripts": {
|
|
66
|
-
"@google/genai": true,
|
|
67
|
-
"protobufjs": true
|
|
68
60
|
}
|
|
69
61
|
}
|
|
@@ -2,56 +2,56 @@
|
|
|
2
2
|
"models": [
|
|
3
3
|
{
|
|
4
4
|
"id": "gemma-4-e2b",
|
|
5
|
-
"label": "Gemma 4 E2B",
|
|
5
|
+
"label": "Gemma 4 E2B (Q4_K_S)",
|
|
6
6
|
"minRamGb": 8,
|
|
7
7
|
"gguf": "unsloth/gemma-4-E2B-it-GGUF/gemma-4-E2B-it-Q4_K_S.gguf",
|
|
8
8
|
"mlx": "mlx-community/gemma-4-e2b-it-4bit"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"id": "qwen-3.5-9b",
|
|
12
|
-
"label": "Qwen 3.5 9B",
|
|
12
|
+
"label": "Qwen 3.5 9B (Q4_K_S)",
|
|
13
13
|
"minRamGb": 16,
|
|
14
14
|
"gguf": "unsloth/Qwen3.5-9B-GGUF/Qwen3.5-9B-UD-Q4_K_S.gguf",
|
|
15
15
|
"mlx": "lmstudio-community/Qwen3.5-9B-MLX-4bit"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"id": "gemma-4-12b-qat",
|
|
19
|
-
"label": "Gemma 4 12B",
|
|
19
|
+
"label": "Gemma 4 12B (Q4_K_XL)",
|
|
20
20
|
"minRamGb": 24,
|
|
21
21
|
"gguf": "unsloth/gemma-4-12B-it-qat-GGUF/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
|
|
22
22
|
"mlx": "mlx-community/gemma-4-12B-it-qat-4bit"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"id": "gemma-4-26b",
|
|
26
|
-
"label": "Gemma 4 26B",
|
|
26
|
+
"label": "Gemma 4 26B (Q4_K_XL)",
|
|
27
27
|
"minRamGb": 32,
|
|
28
28
|
"gguf": "unsloth/gemma-4-26B-A4B-it-qat-GGUF/gemma-4-26B-A4B-it-qat-UD-Q4_K_XL.gguf",
|
|
29
29
|
"mlx": "mlx-community/gemma-4-26b-a4b-4bit"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"id": "qwen-3.6-35b-compact",
|
|
33
|
-
"label": "Qwen 3.6 35B",
|
|
33
|
+
"label": "Qwen 3.6 35B (Q4_K_S)",
|
|
34
34
|
"minRamGb": 32,
|
|
35
35
|
"gguf": "unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_S.gguf",
|
|
36
36
|
"mlx": "mlx-community/Qwen3.6-35B-A3B-4bit"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"id": "qwen-3.6-35b",
|
|
40
|
-
"label": "Qwen 3.6 35B",
|
|
40
|
+
"label": "Qwen 3.6 35B (Q4_K_M)",
|
|
41
41
|
"minRamGb": 48,
|
|
42
42
|
"gguf": "unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf",
|
|
43
43
|
"mlx": ""
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"id": "gemma-4-31b",
|
|
47
|
-
"label": "Gemma 4 31B",
|
|
47
|
+
"label": "Gemma 4 31B (Q4_K_XL)",
|
|
48
48
|
"minRamGb": 64,
|
|
49
49
|
"gguf": "unsloth/gemma-4-31B-it-qat-GGUF/gemma-4-31B-it-qat-UD-Q4_K_XL.gguf",
|
|
50
50
|
"mlx": "mlx-community/gemma-4-31b-4bit"
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"id": "qwen-3.6-27b",
|
|
54
|
-
"label": "Qwen 3.6 27B",
|
|
54
|
+
"label": "Qwen 3.6 27B (Q4_K_M)",
|
|
55
55
|
"minRamGb": 64,
|
|
56
56
|
"gguf": "unsloth/Qwen3.6-27B-MTP-GGUF/Qwen3.6-27B-Q4_K_M.gguf",
|
|
57
57
|
"mlx": "mlx-community/Qwen3.6-27B-4bit"
|
package/src/cli.mjs
CHANGED
|
@@ -5,7 +5,6 @@ import { modelsCommand } from "./commands/models.mjs";
|
|
|
5
5
|
import { runCommand } from "./commands/run.mjs";
|
|
6
6
|
import { statusCommand } from "./commands/status.mjs";
|
|
7
7
|
import { stopCommand } from "./commands/stop.mjs";
|
|
8
|
-
import { benchmarkCommand } from "./commands/benchmark.mjs";
|
|
9
8
|
import { uninstallCommand } from "./commands/uninstall.mjs";
|
|
10
9
|
|
|
11
10
|
async function offerUpdate(argv) {
|
|
@@ -45,7 +44,6 @@ export async function run(argv) {
|
|
|
45
44
|
if (command === "run") return runCommand(argv.slice(1));
|
|
46
45
|
if (command === "status") return statusCommand();
|
|
47
46
|
if (command === "stop") return stopCommand(argv.slice(1));
|
|
48
|
-
if (command === "benchmark") return benchmarkCommand();
|
|
49
47
|
if (command === "uninstall" || command === "--uninstall") return uninstallCommand(argv.slice(1));
|
|
50
48
|
if (command === "--verbose") return mainFlow();
|
|
51
49
|
|
|
@@ -69,10 +67,9 @@ function printHelp() {
|
|
|
69
67
|
["Start", pc.bold("offgrid-ai")],
|
|
70
68
|
["Status", "offgrid-ai status"],
|
|
71
69
|
["Stop", "offgrid-ai stop"],
|
|
72
|
-
["Benchmark", "offgrid-ai benchmark"],
|
|
73
70
|
["Uninstall", "offgrid-ai uninstall"],
|
|
74
71
|
["Version", "offgrid-ai version"],
|
|
75
72
|
]), { formatBorder: pc.cyan }));
|
|
76
|
-
console.log("\n" + renderCard("How it works", "Run offgrid-ai, choose a local model, and start chatting in Pi.\n\nFirst run walks you through missing tools. After that, offgrid-ai remembers your model setup
|
|
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
|
@@ -7,7 +7,7 @@ import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
|
|
|
7
7
|
import { offerManagedOmlxUpdate, hasOmlx } from "../omlx-runtime.mjs";
|
|
8
8
|
import { hasLmStudioInstalled, scanManagedModels } from "../managed.mjs";
|
|
9
9
|
import { recommendedModel } from "../recommendations.mjs";
|
|
10
|
-
import { pc, startInteractive, createPrompt } from "../ui.mjs";
|
|
10
|
+
import { pc, startInteractive, createPrompt, renderCard } from "../ui.mjs";
|
|
11
11
|
import { onboardFlow } from "./onboard.mjs";
|
|
12
12
|
import { modelCommandCenter } from "./models.mjs";
|
|
13
13
|
import { statusCommand } from "./status.mjs";
|
|
@@ -58,9 +58,28 @@ export async function mainFlow() {
|
|
|
58
58
|
if (!process.stdin.isTTY) return await statusCommand();
|
|
59
59
|
|
|
60
60
|
startInteractive("offgrid-ai");
|
|
61
|
+
printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled: await hasOmlx(), profiles });
|
|
62
|
+
console.log(pc.dim(" No models? Pick \"↓ Download a model\" below — offgrid-ai downloads from HuggingFace"));
|
|
63
|
+
console.log("");
|
|
61
64
|
return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
function printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled, profiles }) {
|
|
68
|
+
const omlxServerUp = managedModels.some((m) => m.backendId === "omlx" && m.status === "ok");
|
|
69
|
+
const parts = [
|
|
70
|
+
llamaBinary ? pc.green("llama.cpp ✓") : pc.red("llama.cpp ✗"),
|
|
71
|
+
];
|
|
72
|
+
if (omlxInstalled) {
|
|
73
|
+
parts.push(omlxServerUp ? pc.green("oMLX ✓ server up") : pc.yellow("oMLX ✓ server down"));
|
|
74
|
+
} else {
|
|
75
|
+
parts.push(pc.red("oMLX ✗"));
|
|
76
|
+
}
|
|
77
|
+
parts.push(piInstalled ? pc.green("Pi ✓") : pc.red("Pi ✗"));
|
|
78
|
+
if (profiles.length > 0) parts.push(pc.dim(`${profiles.length} model${profiles.length === 1 ? "" : "s"}`));
|
|
79
|
+
console.log(renderCard("offgrid-ai", parts.join(pc.dim(" · ")), { formatBorder: pc.cyan }));
|
|
80
|
+
console.log("");
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
async function printNoModelsHelp(llamaBinary) {
|
|
65
84
|
console.log(pc.yellow("No models found."));
|
|
66
85
|
console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
|
package/src/commands/models.mjs
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { ensureDirs } from "../config.mjs";
|
|
1
|
+
import { ensureDirs, getModelScanDirs, addModelScanDir, removeModelScanDir, DEFAULT_MODEL_DIRS, findLlamaServer, HF_HUB_DIR } from "../config.mjs";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm, unlink } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
7
|
import { backendFor, BACKENDS } from "../backends.mjs";
|
|
3
8
|
import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
|
|
4
9
|
import { isProfileRunning, isProfileServerUp, modelAvailableOnServer, stopProfile } from "../process.mjs";
|
|
5
|
-
import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
|
|
10
|
+
import { syncPiConfig, removeFromPiConfig, hasPi } from "../harness-pi.mjs";
|
|
11
|
+
import { hasOmlx, offerOmlxRestart } from "../omlx-runtime.mjs";
|
|
6
12
|
import { configureLocalProfile, configureManagedProfile } from "../profile-setup.mjs";
|
|
7
|
-
import {
|
|
13
|
+
import { findOmlxModelDir } from "../mlx-discovery.mjs";
|
|
14
|
+
import { pc, startInteractive, createPrompt, modelSelect, renderCard, renderRows } from "../ui.mjs";
|
|
8
15
|
import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
|
|
9
|
-
import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails,
|
|
16
|
+
import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails, printProfileDetails } from "../model-presenters.mjs";
|
|
10
17
|
import { runProfile } from "./run.mjs";
|
|
11
|
-
|
|
12
|
-
|
|
18
|
+
import { downloadFlow } from "../download.mjs";
|
|
19
|
+
import { execFileAsync } from "../exec.mjs";
|
|
13
20
|
|
|
14
21
|
export async function modelsCommand(argv) {
|
|
15
22
|
await ensureDirs();
|
|
@@ -31,7 +38,19 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
31
38
|
return;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
let catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
|
|
42
|
+
|
|
43
|
+
while (true) {
|
|
44
|
+
const result = await showModelPicker(catalog);
|
|
45
|
+
if (result === "rescan") {
|
|
46
|
+
catalog = await loadModelCatalog();
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function showModelPicker(catalog) {
|
|
35
54
|
const normalized = normalizeCatalog(catalog);
|
|
36
55
|
const allItems = buildCatalogItems(normalized);
|
|
37
56
|
if (allItems.length === 0) {
|
|
@@ -50,8 +69,14 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
50
69
|
if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
// Flag all missing profiles (file missing for llama.cpp, model missing
|
|
73
|
+
// for oMLX managed-server) so actionsForItem/performAction can handle both
|
|
74
|
+
// cases uniformly.
|
|
75
|
+
for (const item of allItems) {
|
|
76
|
+
if (item.type === "profile") {
|
|
77
|
+
item.missing = item.fileMissing || modelMissingIds.has(item.profile.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
55
80
|
|
|
56
81
|
const nameWidth = modelNameWidth(allItems);
|
|
57
82
|
|
|
@@ -82,15 +107,10 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
82
107
|
}
|
|
83
108
|
|
|
84
109
|
const groups = [];
|
|
85
|
-
const backendColors = {
|
|
86
|
-
"llama-cpp": pc.cyan,
|
|
87
|
-
omlx: pc.magenta,
|
|
88
|
-
};
|
|
89
110
|
for (const { backendId, sourceId, items } of byBackend.values()) {
|
|
90
111
|
const backendLabel = backendFor(backendId)?.label ?? backendId;
|
|
91
112
|
const sourceLabel = formatSourceLabel(sourceId);
|
|
92
|
-
const
|
|
93
|
-
const sep = `Inference: ${pc.bold(color(backendLabel))} ${pc.dim("|")} Source: ${sourceLabel} (${items.length})`;
|
|
113
|
+
const sep = ` ${pc.dim(backendLabel + " · " + sourceLabel + " (" + items.length + ")")}`;
|
|
94
114
|
const groupItems = items.map((item) => {
|
|
95
115
|
const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, compact: true });
|
|
96
116
|
return { value: opt.value, label: opt.label, description: opt.description };
|
|
@@ -103,13 +123,31 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
103
123
|
const opt = modelSelectOption(item, { runningProfilesNow, modelMissingIds, nameWidth, compact: true });
|
|
104
124
|
return { value: opt.value, label: opt.label, description: opt.description };
|
|
105
125
|
});
|
|
106
|
-
groups.push({ separator: ` ${pc.
|
|
126
|
+
groups.push({ separator: ` ${pc.yellow("Needs setup (" + setupItems.length + ")")}`, items: groupItems });
|
|
107
127
|
}
|
|
108
128
|
|
|
129
|
+
groups.push({ separator: " ", items: [
|
|
130
|
+
{ value: "__download__", label: `${pc.dim("○")} ${pc.green("↓ Download a model")}` },
|
|
131
|
+
{ value: "__settings__", label: `${pc.dim("○")} ${pc.cyan("⚙ Status & settings")}` },
|
|
132
|
+
] });
|
|
133
|
+
|
|
109
134
|
const prompt = createPrompt();
|
|
110
135
|
try {
|
|
111
136
|
const selected = await modelSelect("Select a model", groups, { pageSize: 20 });
|
|
112
137
|
if (!selected) return;
|
|
138
|
+
|
|
139
|
+
if (selected === "__settings__") {
|
|
140
|
+
await settingsFlow(prompt);
|
|
141
|
+
console.log("");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (selected === "__download__") {
|
|
146
|
+
await downloadFlow(prompt);
|
|
147
|
+
console.log("");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
113
151
|
const item = allItems.find((candidate) => itemKey(candidate) === selected);
|
|
114
152
|
if (!item) return;
|
|
115
153
|
|
|
@@ -117,6 +155,7 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
117
155
|
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
118
156
|
if (!action) return;
|
|
119
157
|
await performAction(prompt, action, item);
|
|
158
|
+
console.log("");
|
|
120
159
|
} finally {
|
|
121
160
|
prompt.close();
|
|
122
161
|
}
|
|
@@ -128,13 +167,13 @@ function formatActions(rawActions) {
|
|
|
128
167
|
const width = Math.max(17, maxName + 2);
|
|
129
168
|
return rawActions.map((a) => {
|
|
130
169
|
const name = a.dimmed ? pc.dim(pc.strikethrough(a.name.padEnd(width).slice(0, width))) : pc.bold(a.name.padEnd(width).slice(0, width));
|
|
131
|
-
const desc = a.dimmed ? pc.red("
|
|
170
|
+
const desc = a.dimmed ? pc.red("not available") : pc.dim(a.desc);
|
|
132
171
|
return { value: a.value, label: name + sep + desc };
|
|
133
172
|
});
|
|
134
173
|
}
|
|
135
174
|
|
|
136
175
|
function actionsForItem(item) {
|
|
137
|
-
const missing = item.type === "profile" && item.
|
|
176
|
+
const missing = item.type === "profile" && item.missing;
|
|
138
177
|
if (item.type === "profile") {
|
|
139
178
|
const available = [
|
|
140
179
|
{ value: "inspect", name: "Details", desc: "Paths, ports, flags" },
|
|
@@ -144,21 +183,14 @@ function actionsForItem(item) {
|
|
|
144
183
|
{ value: "run", name: "Start chatting", desc: "Launch and open Pi" },
|
|
145
184
|
{ value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings" },
|
|
146
185
|
);
|
|
147
|
-
const backend = backendFor(item.profile.backend);
|
|
148
|
-
if (backend.type === "local-server" || backend.type === "managed-server") {
|
|
149
|
-
available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run" });
|
|
150
|
-
}
|
|
151
186
|
}
|
|
152
|
-
available.push({ value: "
|
|
187
|
+
available.push({ value: "remove_config", name: "Remove configuration", desc: "Delete this setup, keep model files" });
|
|
188
|
+
available.push({ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" });
|
|
153
189
|
if (missing) {
|
|
154
190
|
available.unshift(
|
|
155
191
|
{ value: "run", name: "Start chatting", desc: "Launch and open Pi", dimmed: true },
|
|
156
192
|
{ value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings", dimmed: true },
|
|
157
193
|
);
|
|
158
|
-
const backend = backendFor(item.profile.backend);
|
|
159
|
-
if (backend.type === "local-server" || backend.type === "managed-server") {
|
|
160
|
-
available.push({ value: "benchmark", name: "Benchmark", desc: "Prepare a benchmark run", dimmed: true });
|
|
161
|
-
}
|
|
162
194
|
}
|
|
163
195
|
return formatActions(available);
|
|
164
196
|
}
|
|
@@ -166,18 +198,22 @@ function actionsForItem(item) {
|
|
|
166
198
|
return formatActions([
|
|
167
199
|
{ value: "setup", name: "Set up", desc: "Configure and save" },
|
|
168
200
|
{ value: "inspect", name: "Details", desc: "Model info" },
|
|
201
|
+
{ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
|
|
169
202
|
]);
|
|
170
203
|
}
|
|
171
204
|
return formatActions([
|
|
172
205
|
{ value: "setup", name: "Set up", desc: `Connect via ${BACKENDS[item.backendId].label}` },
|
|
173
206
|
{ value: "inspect", name: "Details", desc: "Model info" },
|
|
207
|
+
{ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
|
|
174
208
|
]);
|
|
175
209
|
}
|
|
176
210
|
|
|
177
211
|
async function performAction(prompt, action, item) {
|
|
178
|
-
const missing = item.type === "profile" && item.
|
|
179
|
-
if (missing && ["run", "reconfigure"
|
|
180
|
-
|
|
212
|
+
const missing = item.type === "profile" && item.missing;
|
|
213
|
+
if (missing && ["run", "reconfigure"].includes(action)) {
|
|
214
|
+
const backend = item.type === "profile" ? backendFor(item.profile.backend) : null;
|
|
215
|
+
const reason = backend?.type === "managed-server" ? "model is no longer available on the server" : "model file is no longer on disk";
|
|
216
|
+
console.log(pc.red(`This model's ${reason}. Remove the setup or restore the model.`));
|
|
181
217
|
return;
|
|
182
218
|
}
|
|
183
219
|
if (action === "inspect") {
|
|
@@ -185,17 +221,10 @@ async function performAction(prompt, action, item) {
|
|
|
185
221
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
186
222
|
return printGgufModelDetails(item.model, item.drafter);
|
|
187
223
|
}
|
|
188
|
-
if (action === "benchmark") {
|
|
189
|
-
if (item.type === "profile") {
|
|
190
|
-
const { benchmarkForProfile } = await import("../benchmark.mjs");
|
|
191
|
-
return await benchmarkForProfile(await readProfile(item.profile.id));
|
|
192
|
-
}
|
|
193
|
-
const { benchmarkFlow } = await import("../benchmark.mjs");
|
|
194
|
-
return await benchmarkFlow();
|
|
195
|
-
}
|
|
196
224
|
if (action === "run") return await runItem(item);
|
|
197
225
|
if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item);
|
|
198
|
-
if (action === "
|
|
226
|
+
if (action === "remove_config" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
|
|
227
|
+
if (action === "delete_model") return await deleteModelFromSource(prompt, item);
|
|
199
228
|
}
|
|
200
229
|
|
|
201
230
|
async function runItem(item) {
|
|
@@ -255,4 +284,232 @@ async function removeProfileInteractive(id) {
|
|
|
255
284
|
await removeFromPiConfig(profile);
|
|
256
285
|
await deleteProfile(id);
|
|
257
286
|
console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Delete model from source ───────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/** Extract HuggingFace repo ID from a cache path. */
|
|
292
|
+
function hfRepoFromPath(path) {
|
|
293
|
+
const hubPart = path.slice(HF_HUB_DIR.length);
|
|
294
|
+
const match = hubPart.match(/models--(.+?)(?=\/|$)/);
|
|
295
|
+
if (!match) return null;
|
|
296
|
+
return match[1].replace(/--/g, "/");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Determine where a model's files live on disk. */
|
|
300
|
+
async function modelLocationForItem(item) {
|
|
301
|
+
if (item.type === "profile") {
|
|
302
|
+
const backend = backendFor(item.profile.backend);
|
|
303
|
+
if (backend.type === "managed-server") {
|
|
304
|
+
const modelId = item.profile.omlxModel || item.profile.modelAlias || item.profile.id;
|
|
305
|
+
// oMLX model IDs may not include the org prefix, so search recursively
|
|
306
|
+
const dir = await findOmlxModelDir(modelId);
|
|
307
|
+
return { kind: "mlx", dir: dir ?? join(homedir(), ".omlx", "models", ...modelId.replace(/--/g, "/").split("/").filter(Boolean)), modelId };
|
|
308
|
+
}
|
|
309
|
+
const modelPath = item.profile.modelPath;
|
|
310
|
+
if (!modelPath) return { kind: "unknown" };
|
|
311
|
+
if (modelPath.startsWith(HF_HUB_DIR)) {
|
|
312
|
+
return { kind: "hf-cache", path: modelPath, repoId: hfRepoFromPath(modelPath) };
|
|
313
|
+
}
|
|
314
|
+
return { kind: "file", path: modelPath };
|
|
315
|
+
}
|
|
316
|
+
if (item.type === "new") {
|
|
317
|
+
const modelPath = item.model?.path;
|
|
318
|
+
if (!modelPath) return { kind: "unknown" };
|
|
319
|
+
if (modelPath.startsWith(HF_HUB_DIR)) {
|
|
320
|
+
return { kind: "hf-cache", path: modelPath, repoId: hfRepoFromPath(modelPath) };
|
|
321
|
+
}
|
|
322
|
+
return { kind: "file", path: modelPath };
|
|
323
|
+
}
|
|
324
|
+
if (item.type === "managed") {
|
|
325
|
+
const modelId = item.model?.id;
|
|
326
|
+
if (!modelId) return { kind: "unknown" };
|
|
327
|
+
// oMLX model IDs may not include the org prefix, so search recursively
|
|
328
|
+
const dir = await findOmlxModelDir(modelId);
|
|
329
|
+
return { kind: "mlx", dir: dir ?? join(homedir(), ".omlx", "models", ...modelId.replace(/--/g, "/").split("/").filter(Boolean)), modelId };
|
|
330
|
+
}
|
|
331
|
+
return { kind: "unknown" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function deleteModelFromSource(prompt, item) {
|
|
335
|
+
const loc = await modelLocationForItem(item);
|
|
336
|
+
|
|
337
|
+
if (loc.kind === "unknown") {
|
|
338
|
+
console.log(pc.yellow("Could not determine where this model's files are located."));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Show what will be deleted
|
|
343
|
+
let locationLabel;
|
|
344
|
+
if (loc.kind === "hf-cache") {
|
|
345
|
+
locationLabel = loc.path ?? loc.repoId;
|
|
346
|
+
} else if (loc.kind === "mlx") {
|
|
347
|
+
locationLabel = loc.dir;
|
|
348
|
+
} else if (loc.kind === "file") {
|
|
349
|
+
locationLabel = loc.path;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(pc.yellow("\nThis will permanently delete " + (item.type === "profile" ? "the configuration and the model from:" : "the model from:")));
|
|
353
|
+
console.log(pc.dim(` ${locationLabel}`));
|
|
354
|
+
|
|
355
|
+
const confirmed = await prompt.yesNo("Delete this model?", false);
|
|
356
|
+
if (!confirmed) {
|
|
357
|
+
console.log(pc.dim("Cancelled."));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Stop running server if needed
|
|
362
|
+
if (item.type === "profile" && await isProfileRunning(item.profile)) {
|
|
363
|
+
console.log(pc.dim("Stopping running server..."));
|
|
364
|
+
await stopProfile(item.profile);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Delete files
|
|
368
|
+
if (loc.kind === "hf-cache" && loc.repoId) {
|
|
369
|
+
const cacheDir = join(HF_HUB_DIR, `models--${loc.repoId.replace(/\//g, "--")}`);
|
|
370
|
+
try {
|
|
371
|
+
const { stdout } = await execFileAsync("hf", ["cache", "rm", `model/${loc.repoId}`, "--yes"], { timeout: 30000 });
|
|
372
|
+
if (stdout.trim()) console.log(pc.dim(stdout.trim()));
|
|
373
|
+
// Verify the directory is actually gone
|
|
374
|
+
if (existsSync(cacheDir)) {
|
|
375
|
+
console.log(pc.red(`✗ Model still exists at ${cacheDir}`));
|
|
376
|
+
console.log(pc.dim(`Delete manually: hf cache rm model/${loc.repoId}`));
|
|
377
|
+
} else {
|
|
378
|
+
console.log(pc.green(`✓ Deleted ${loc.repoId} from HuggingFace cache`));
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const detail = err.stderr?.trim() || err.message;
|
|
382
|
+
console.log(pc.red(`✗ Failed: ${detail}`));
|
|
383
|
+
console.log(pc.dim(`Delete manually: hf cache rm model/${loc.repoId}`));
|
|
384
|
+
}
|
|
385
|
+
} else if (loc.kind === "mlx") {
|
|
386
|
+
const omlxModelsRoot = join(homedir(), ".omlx", "models");
|
|
387
|
+
// Safety guard: never delete outside ~/.omlx/models/
|
|
388
|
+
if (!loc.dir.startsWith(omlxModelsRoot + "/") && loc.dir !== omlxModelsRoot) {
|
|
389
|
+
console.log(pc.red(`✗ Refusing to delete: path is outside ~/.omlx/models/`));
|
|
390
|
+
console.log(pc.dim(` Target: ${loc.dir}`));
|
|
391
|
+
console.log(pc.dim(`Delete manually if needed: rm -rf ${loc.dir}`));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!existsSync(loc.dir)) {
|
|
395
|
+
console.log(pc.yellow(`Directory not found: ${loc.dir}`));
|
|
396
|
+
console.log(pc.dim("Model files may have already been removed, or oMLX loaded them from a different location."));
|
|
397
|
+
} else {
|
|
398
|
+
try {
|
|
399
|
+
await rm(loc.dir, { recursive: true, force: true });
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.log(pc.red(`✗ Failed: ${err.message}`));
|
|
402
|
+
console.log(pc.dim(`Delete manually: rm -rf ${loc.dir}`));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Verify deletion
|
|
406
|
+
if (existsSync(loc.dir)) {
|
|
407
|
+
console.log(pc.red(`✗ Directory still exists: ${loc.dir}`));
|
|
408
|
+
console.log(pc.dim(`Delete manually: rm -rf ${loc.dir}`));
|
|
409
|
+
} else {
|
|
410
|
+
console.log(pc.green(`✓ Deleted ${loc.dir}`));
|
|
411
|
+
await offerOmlxRestart(prompt, "to update its model list");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else if (loc.kind === "file") {
|
|
415
|
+
if (!existsSync(loc.path)) {
|
|
416
|
+
console.log(pc.yellow(`File not found: ${loc.path}`));
|
|
417
|
+
console.log(pc.dim("Model file may have already been removed."));
|
|
418
|
+
} else {
|
|
419
|
+
try {
|
|
420
|
+
await unlink(loc.path);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
console.log(pc.red(`✗ Failed: ${err.message}`));
|
|
423
|
+
console.log(pc.dim(`Delete manually: rm ${loc.path}`));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Verify deletion
|
|
427
|
+
if (existsSync(loc.path)) {
|
|
428
|
+
console.log(pc.red(`✗ File still exists: ${loc.path}`));
|
|
429
|
+
console.log(pc.dim(`Delete manually: rm ${loc.path}`));
|
|
430
|
+
} else {
|
|
431
|
+
console.log(pc.green(`✓ Deleted ${loc.path}`));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Remove profile configuration if one exists
|
|
437
|
+
if (item.type === "profile") {
|
|
438
|
+
await removeFromPiConfig(item.profile);
|
|
439
|
+
await deleteProfile(item.profile.id);
|
|
440
|
+
console.log(pc.dim(`Removed configuration: ${item.profile.id}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Settings & discovery path management ───────────────────────────────────
|
|
445
|
+
|
|
446
|
+
async function settingsFlow(prompt) {
|
|
447
|
+
while (true) {
|
|
448
|
+
const llamaBinary = await findLlamaServer();
|
|
449
|
+
const omlxInstalled = await hasOmlx();
|
|
450
|
+
const piInstalled = await hasPi();
|
|
451
|
+
|
|
452
|
+
let omlxServerUp = false;
|
|
453
|
+
if (omlxInstalled) {
|
|
454
|
+
try {
|
|
455
|
+
const res = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(2000) });
|
|
456
|
+
omlxServerUp = res.ok;
|
|
457
|
+
} catch { /* server down */ }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.log("");
|
|
461
|
+
console.log(renderCard("Runtime status", renderRows([
|
|
462
|
+
["llama.cpp", llamaBinary ? pc.green("✓ ") + pc.dim(llamaBinary) : pc.red("✗ not found")],
|
|
463
|
+
["oMLX", omlxInstalled ? (omlxServerUp ? pc.green("✓ server up") : pc.yellow("✓ installed · server down")) : pc.red("✗ not found")],
|
|
464
|
+
["Pi", piInstalled ? pc.green("✓ installed") : pc.red("✗ not found")],
|
|
465
|
+
]), { formatBorder: pc.cyan }));
|
|
466
|
+
|
|
467
|
+
const scanDirs = await getModelScanDirs();
|
|
468
|
+
const defaultSet = new Set(DEFAULT_MODEL_DIRS);
|
|
469
|
+
const pathLabels = new Map([
|
|
470
|
+
[join(homedir(), ".lmstudio", "models"), "LM Studio downloads"],
|
|
471
|
+
[join(homedir(), ".omlx", "models"), "oMLX downloads"],
|
|
472
|
+
[HF_HUB_DIR, "HuggingFace CLI downloads"],
|
|
473
|
+
]);
|
|
474
|
+
const pathRows = scanDirs.map((dir) => {
|
|
475
|
+
const exists = existsSync(dir);
|
|
476
|
+
const isBuiltin = defaultSet.has(dir);
|
|
477
|
+
const desc = pathLabels.get(dir);
|
|
478
|
+
const label = `${exists ? pc.green("✓") : pc.red("✗")} ${dir}`;
|
|
479
|
+
const tags = [desc, isBuiltin ? "built-in" : "custom"].filter(Boolean).join(pc.dim(" · "));
|
|
480
|
+
return [label, pc.dim(tags)];
|
|
481
|
+
});
|
|
482
|
+
console.log("");
|
|
483
|
+
console.log(renderCard("Discovery paths", renderRows(pathRows), { formatBorder: pc.magenta }));
|
|
484
|
+
|
|
485
|
+
const customDirs = scanDirs.filter((d) => !defaultSet.has(d));
|
|
486
|
+
const choices = [
|
|
487
|
+
{ value: "add", label: "Add discovery path" },
|
|
488
|
+
...(customDirs.length > 0 ? [{ value: "remove", label: "Remove discovery path" }] : []),
|
|
489
|
+
{ value: "done", label: "Done" },
|
|
490
|
+
];
|
|
491
|
+
const action = await prompt.choice("Settings", choices, "done");
|
|
492
|
+
|
|
493
|
+
if (!action || action === "done") return;
|
|
494
|
+
|
|
495
|
+
if (action === "add") {
|
|
496
|
+
const dir = await prompt.text("Path to model directory", "");
|
|
497
|
+
if (!dir || !dir.trim()) continue;
|
|
498
|
+
const cleanDir = dir.trim();
|
|
499
|
+
if (!existsSync(cleanDir)) {
|
|
500
|
+
console.log(pc.red(`Directory not found: ${cleanDir}`));
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
await addModelScanDir(cleanDir);
|
|
504
|
+
console.log(pc.green(`Added: ${cleanDir}`));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (action === "remove") {
|
|
508
|
+
const removeChoices = customDirs.map((d) => ({ value: d, label: d }));
|
|
509
|
+
const toRemove = await prompt.choice("Remove path", removeChoices);
|
|
510
|
+
if (!toRemove) continue;
|
|
511
|
+
await removeModelScanDir(toRemove);
|
|
512
|
+
console.log(pc.green(`Removed: ${toRemove}`));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
258
515
|
}
|