offgrid-ai 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ export async function benchmarkCommand() {
2
+ const { benchmarkFlow } = await import("../benchmark.mjs");
3
+ return await benchmarkFlow();
4
+ }
@@ -0,0 +1,89 @@
1
+ import { findLlamaServer, ensureDirs } from "../config.mjs";
2
+ import { backendFor } from "../backends.mjs";
3
+ import { scanGgufModels } from "../scan.mjs";
4
+ import { loadProfiles } from "../profiles.mjs";
5
+ import { hasPi } from "../harness-pi.mjs";
6
+ import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
7
+ import { hasLmStudioInstalled, hasOllamaInstalled, hasOmlxInstalled, scanManagedModels } from "../managed.mjs";
8
+ import { recommendedModel } from "../recommendations.mjs";
9
+ import { pc, startInteractive, createPrompt } from "../ui.mjs";
10
+ import { onboardFlow } from "./onboard.mjs";
11
+ import { modelCommandCenter } from "./models.mjs";
12
+ import { statusCommand } from "./status.mjs";
13
+
14
+ export async function mainFlow() {
15
+ await ensureDirs();
16
+
17
+ if (process.stdin.isTTY) {
18
+ const runtimePrompt = createPrompt();
19
+ try {
20
+ await offerManagedLlamaRuntimeUpdate(runtimePrompt);
21
+ } finally {
22
+ runtimePrompt.close();
23
+ }
24
+ }
25
+
26
+ const llamaBinary = await findLlamaServer();
27
+ const { models: ggufModels, drafters } = await scanGgufModels();
28
+ const managedModels = await scanManagedModels();
29
+ const profiles = await loadProfiles();
30
+ const hasAnyBackend = llamaBinary || managedModels.some((item) => item.models.length > 0);
31
+ const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.models.length > 0);
32
+
33
+ const piInstalled = await hasPi();
34
+ const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
35
+ const missingDeps = [];
36
+ if (needsLlama && !llamaBinary) missingDeps.push("llama-server");
37
+ if (!piInstalled) missingDeps.push("Pi");
38
+ if (missingDeps.length > 0) {
39
+ if (!process.stdin.isTTY) throw new Error(`Missing dependencies: ${missingDeps.join(", ")}. Run offgrid-ai interactively to install.`);
40
+ console.log(pc.yellow(`Missing: ${missingDeps.join(", ")}`));
41
+ console.log(pc.dim("offgrid-ai needs these to run. Let's finish setup.\n"));
42
+ return await onboardFlow();
43
+ }
44
+
45
+ if (!hasAnyBackend && !hasAnyModels && profiles.length === 0) {
46
+ if (!process.stdin.isTTY) throw new Error("No local LLM backends found. Run offgrid-ai interactively to set up.");
47
+ return await onboardFlow();
48
+ }
49
+
50
+ if (!hasAnyModels && profiles.length === 0) {
51
+ if (!process.stdin.isTTY) throw new Error("No models found. Download a model, then run offgrid-ai.");
52
+ await printNoModelsHelp(llamaBinary);
53
+ return;
54
+ }
55
+
56
+ if (!process.stdin.isTTY) return await statusCommand();
57
+
58
+ startInteractive("offgrid-ai");
59
+ return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
60
+ }
61
+
62
+ async function printNoModelsHelp(llamaBinary) {
63
+ console.log(pc.yellow("No models found."));
64
+ console.log(pc.dim("You need to download a model to use offgrid-ai.\n"));
65
+
66
+ const [ollamaInstalled, omlxInstalled] = await Promise.all([hasOllamaInstalled(), hasOmlxInstalled()]);
67
+ const lmStudioInstalled = hasLmStudioInstalled();
68
+ const hasBackends = llamaBinary || ollamaInstalled || omlxInstalled || lmStudioInstalled;
69
+ if (!hasBackends) {
70
+ console.log(pc.dim("Run offgrid-ai to install a backend and download a model."));
71
+ return;
72
+ }
73
+
74
+ console.log(pc.bold("Backend status:"));
75
+ console.log(` ${lmStudioInstalled ? pc.green("✓") : pc.red("✗")} LM Studio ${lmStudioInstalled ? "— installed" : "— not installed"}`);
76
+ console.log(` ${ollamaInstalled ? pc.green("✓") : pc.red("✗")} Ollama ${ollamaInstalled ? "— installed" : "— not installed"}`);
77
+ console.log(` ${omlxInstalled ? pc.green("✓") : pc.red("✗")} oMLX ${omlxInstalled ? "— installed" : "— not installed"}`);
78
+ console.log(` ${llamaBinary ? pc.green("✓") : pc.red("✗")} llama-server ${llamaBinary ? "— installed" : "— not installed"}`);
79
+ console.log();
80
+
81
+ const model = recommendedModel();
82
+ console.log(pc.bold("Next step — download a model:"));
83
+ if (lmStudioInstalled) {
84
+ console.log(" Open LM Studio → browse models → download");
85
+ console.log(pc.dim(` Recommended: ${model.label}`));
86
+ }
87
+ if (ollamaInstalled) console.log(pc.bold(` ollama pull ${model.ollama}`));
88
+ if (omlxInstalled) console.log(pc.bold(" omlx start"));
89
+ }
@@ -0,0 +1,170 @@
1
+ import { ensureDirs } from "../config.mjs";
2
+ import { backendFor, BACKENDS } from "../backends.mjs";
3
+ import { createProfileFromModel, readProfile, saveProfile, deleteProfile } from "../profiles.mjs";
4
+ import { isProfileRunning, stopProfile } from "../process.mjs";
5
+ import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
6
+ import { configureLocalProfile } from "../profile-setup.mjs";
7
+ import { pc, renderRows, renderCard, startInteractive, createPrompt } from "../ui.mjs";
8
+ import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
9
+ import { isProfileFileMissing } from "../model-summary.mjs";
10
+ import { modelSelectOption, printGgufModelDetails, printManagedModelDetails, printModelCards, printProfileDetails } from "../model-presenters.mjs";
11
+ import { runProfile } from "./run.mjs";
12
+
13
+ export async function modelsCommand(argv) {
14
+ await ensureDirs();
15
+ const catalog = await loadModelCatalog();
16
+
17
+ if (argv[0]) {
18
+ await printProfileDetails(await readProfile(argv[0]));
19
+ return;
20
+ }
21
+
22
+ if (process.stdin.isTTY) startInteractive("offgrid-ai");
23
+ return await modelCommandCenter(catalog);
24
+ }
25
+
26
+ export async function modelCommandCenter(initialCatalog) {
27
+ if (!process.stdin.isTTY) {
28
+ const allItems = buildCatalogItems(normalizeCatalog(initialCatalog));
29
+ for (const item of allItems) console.log(item.label);
30
+ return;
31
+ }
32
+
33
+ const catalog = initialCatalog.newModels ? initialCatalog : await loadModelCatalog();
34
+ const normalized = normalizeCatalog(catalog);
35
+ const allItems = buildCatalogItems(normalized);
36
+ if (allItems.length === 0) {
37
+ console.log(pc.dim("No models found."));
38
+ return;
39
+ }
40
+
41
+ const runningProfilesNow = [];
42
+ for (const profile of normalized.profiles) {
43
+ if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
44
+ }
45
+ printWorkspaceSummary(normalized, runningProfilesNow);
46
+ printModelCards(allItems, { runningProfilesNow, drafters: normalized.drafters });
47
+
48
+ const prompt = createPrompt();
49
+ try {
50
+ const selected = await prompt.choice("Select a model", allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters })));
51
+ if (!selected) return;
52
+ const item = allItems.find((candidate) => itemKey(candidate) === selected);
53
+ if (!item) return;
54
+
55
+ const actions = actionsForItem(item);
56
+ const action = await prompt.choice(item.label, actions, actions[0].value);
57
+ if (!action) return;
58
+ await performAction(prompt, action, item);
59
+ } finally {
60
+ prompt.close();
61
+ }
62
+ }
63
+
64
+ function printWorkspaceSummary(normalized, runningProfilesNow) {
65
+ const fileMissingCount = normalized.profiles.filter((profile) => isProfileFileMissing(profile)).length;
66
+ const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
67
+ console.log("\n" + renderCard("Your local AI workspace", renderRows([
68
+ ["Setups", `${normalized.profiles.length} saved`],
69
+ ["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
70
+ ["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
71
+ ["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
72
+ ]), { formatBorder: summaryBorder }));
73
+ }
74
+
75
+ function actionsForItem(item) {
76
+ if (item.type === "profile") {
77
+ const actions = [
78
+ { value: "run", label: "Start chatting", hint: "Launch and open Pi" },
79
+ { value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
80
+ { value: "inspect", label: "Details", hint: "Paths, ports, flags" },
81
+ ];
82
+ const backend = backendFor(item.profile.backend);
83
+ if (backend.type === "local-server" || backend.type === "managed-server") actions.push({ value: "benchmark", label: "Benchmark", hint: "Prepare a benchmark run" });
84
+ if (!item.fileMissing) actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
85
+ return actions;
86
+ }
87
+ if (item.type === "new") {
88
+ return [
89
+ { value: "setup", label: "Set up", hint: "Configure and save" },
90
+ { value: "inspect", label: "Details", hint: "Model info" },
91
+ ];
92
+ }
93
+ return [
94
+ { value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
95
+ { value: "inspect", label: "Details", hint: "Model info" },
96
+ ];
97
+ }
98
+
99
+ async function performAction(prompt, action, item) {
100
+ if (action === "inspect") {
101
+ if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
102
+ if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
103
+ return printGgufModelDetails(item.model, item.drafter);
104
+ }
105
+ if (action === "benchmark") {
106
+ if (item.type === "profile") {
107
+ const { benchmarkForProfile } = await import("../benchmark.mjs");
108
+ return await benchmarkForProfile(await readProfile(item.profile.id));
109
+ }
110
+ const { benchmarkFlow } = await import("../benchmark.mjs");
111
+ return await benchmarkFlow();
112
+ }
113
+ if (action === "run") return await runItem(prompt, item);
114
+ if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item, action);
115
+ if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
116
+ }
117
+
118
+ async function runItem(prompt, item) {
119
+ if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
120
+ const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
121
+ const configured = await configureLocalProfile(prompt, profile);
122
+ if (!configured) return;
123
+ await saveProfile(configured);
124
+ await syncPiConfig(configured);
125
+ return await runProfile(configured);
126
+ }
127
+
128
+ async function setupItem(prompt, item, action) {
129
+ if (item.type === "profile") {
130
+ const configured = await configureLocalProfile(prompt, await readProfile(item.profile.id));
131
+ if (!configured) return;
132
+ await saveProfile(configured, { writeCommand: true });
133
+ return await syncPiConfig(configured);
134
+ }
135
+ if (item.type === "managed") {
136
+ const profile = createManagedProfile(item.model, item.backendId);
137
+ await saveProfile(profile);
138
+ return await syncPiConfig(profile);
139
+ }
140
+ const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
141
+ const configured = await configureLocalProfile(prompt, profile);
142
+ if (!configured) return;
143
+ await saveProfile(configured, { writeCommand: action === "reconfigure" });
144
+ return await syncPiConfig(configured);
145
+ }
146
+
147
+ async function removeProfileInteractive(id) {
148
+ const profile = await readProfile(id);
149
+ if (!process.stdin.isTTY) {
150
+ console.log(pc.red(`Use --force to remove ${id} non-interactively.`));
151
+ return;
152
+ }
153
+ const prompt = createPrompt();
154
+ try {
155
+ const confirmed = await prompt.yesNo(`Remove ${profile.label} (${profile.id})?`, false);
156
+ if (!confirmed) {
157
+ console.log(pc.dim("Cancelled."));
158
+ return;
159
+ }
160
+ } finally {
161
+ prompt.close();
162
+ }
163
+ if (await isProfileRunning(profile)) {
164
+ console.log(pc.yellow("Stopping running server..."));
165
+ await stopProfile(profile);
166
+ }
167
+ await removeFromPiConfig(profile);
168
+ await deleteProfile(id);
169
+ console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
170
+ }
@@ -0,0 +1,177 @@
1
+ import { existsSync } from "node:fs";
2
+ import { ensureDirs, findLlamaServer, hasHomebrew } from "../config.mjs";
3
+ import { BACKENDS } from "../backends.mjs";
4
+ import { scanGgufModels } from "../scan.mjs";
5
+ import { hasPi } from "../harness-pi.mjs";
6
+ import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
7
+ import { scanManagedModels } from "../managed.mjs";
8
+ import { BACKEND_INSTALL_CHOICES, BACKEND_INSTALLERS } from "../backend-installers.mjs";
9
+ import { installedRamGB, recommendedModel } from "../recommendations.mjs";
10
+ import { runCommand } from "../exec.mjs";
11
+ import { pc, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
12
+
13
+ export async function onboardFlow() {
14
+ await ensureDirs();
15
+ startInteractive("offgrid-ai setup");
16
+ const prompt = createPrompt();
17
+ const verbose = process.argv.includes("--verbose");
18
+ const run = (cmd, args, label) => runCommand(cmd, args, { label, verbose });
19
+
20
+ try {
21
+ console.log(pc.bold("Welcome to offgrid-ai!"));
22
+ console.log(pc.dim("Let's make sure you have everything you need to run local models.\n"));
23
+
24
+ const llamaBinary = await ensureLlamaRuntime(prompt);
25
+ if (!(await ensurePi(prompt, run))) return;
26
+
27
+ const { models: ggufModels } = await scanGgufModels();
28
+ const managedModels = await scanManagedModels();
29
+ const totalManaged = managedModels.reduce((sum, item) => sum + item.models.length, 0);
30
+ const hasModels = ggufModels.length > 0 || totalManaged > 0;
31
+
32
+ if (hasModels) {
33
+ printFoundModels(ggufModels, managedModels, llamaBinary);
34
+ } else {
35
+ await offerBackendInstall(prompt, run);
36
+ return;
37
+ }
38
+
39
+ console.log(pc.green("\n✓ Setup complete! Run offgrid-ai to pick and run a model."));
40
+ } finally {
41
+ prompt.close();
42
+ }
43
+ }
44
+
45
+ async function ensureLlamaRuntime(prompt) {
46
+ let llamaBinary = await findLlamaServer();
47
+ if (!llamaBinary) {
48
+ console.log(renderSection("llama.cpp runtime", renderRows([
49
+ ["Status", pc.yellow("not installed")],
50
+ ["Used for", "local GGUF models"],
51
+ ["Install", "managed by offgrid-ai under ~/.offgrid-ai/runtime"],
52
+ ]), { formatBorder: pc.cyan }));
53
+ await offerManagedLlamaRuntimeUpdate(prompt);
54
+ llamaBinary = await findLlamaServer();
55
+ if (!llamaBinary) 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."));
56
+ }
57
+ if (llamaBinary) console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
58
+ return llamaBinary;
59
+ }
60
+
61
+ async function ensurePi(prompt, run) {
62
+ if (await hasPi()) {
63
+ console.log(pc.green("✓ Pi found"));
64
+ return true;
65
+ }
66
+ const install = await prompt.yesNo("Pi coding agent is required to chat with models. Install via npm?", true);
67
+ if (!install) {
68
+ console.log(pc.red("offgrid-ai needs Pi to run models."));
69
+ console.log(pc.dim("Install it manually: npm install -g --ignore-scripts @earendil-works/pi-coding-agent"));
70
+ return false;
71
+ }
72
+ console.log(pc.cyan("Installing Pi..."));
73
+ try {
74
+ await run("npm", ["install", "-g", "--ignore-scripts", "@earendil-works/pi-coding-agent"], "Pi");
75
+ } catch {
76
+ console.log(pc.red("✗ Failed to install Pi."));
77
+ console.log(pc.dim("Install it manually: npm install -g --ignore-scripts @earendil-works/pi-coding-agent"));
78
+ return false;
79
+ }
80
+ if (!(await hasPi())) {
81
+ console.log(pc.yellow("Pi was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
82
+ return false;
83
+ }
84
+ console.log(pc.green("✓ Pi found"));
85
+ return true;
86
+ }
87
+
88
+ function printFoundModels(ggufModels, managedModels, llamaBinary) {
89
+ if (ggufModels.length > 0) {
90
+ console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
91
+ if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
92
+ }
93
+ for (const { backendId, models } of managedModels) {
94
+ if (models.length > 0) console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
95
+ }
96
+ }
97
+
98
+ async function offerBackendInstall(prompt, run) {
99
+ console.log(pc.yellow("\nNo models found."));
100
+ console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
101
+ const choice = await prompt.choice("Install a model backend?", BACKEND_INSTALL_CHOICES, "lmstudio");
102
+ const model = recommendedModel();
103
+
104
+ if (choice === "skip") {
105
+ console.log(pc.dim("Run offgrid-ai again when you've set up a model backend."));
106
+ return;
107
+ }
108
+ if (choice === "all") {
109
+ await installAllBackends(prompt, run, model);
110
+ return;
111
+ }
112
+ await installBackend(prompt, run, choice, model);
113
+ }
114
+
115
+ async function ensureHomebrewFor(prompt, run, label) {
116
+ if (await hasHomebrew()) return true;
117
+ const install = await prompt.yesNo(`Homebrew is needed to install ${label}. Install Homebrew now?`, true);
118
+ if (!install) {
119
+ console.log(pc.dim(`Install ${label} manually, or install Homebrew from https://brew.sh and run offgrid-ai again.`));
120
+ return false;
121
+ }
122
+ console.log(pc.cyan("Installing Homebrew..."));
123
+ try {
124
+ await run("/bin/bash", ["-c", "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""], "Homebrew");
125
+ for (const path of ["/opt/homebrew/bin", "/usr/local/bin"]) {
126
+ if (existsSync(path)) {
127
+ process.env.PATH = `${path}:${process.env.PATH}`;
128
+ break;
129
+ }
130
+ }
131
+ } catch {
132
+ console.log(pc.red("✗ Homebrew installation failed."));
133
+ console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
134
+ return false;
135
+ }
136
+ if (!(await hasHomebrew())) {
137
+ console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
138
+ return false;
139
+ }
140
+ console.log(pc.green("✓ Homebrew found"));
141
+ return true;
142
+ }
143
+
144
+ async function installBackend(prompt, run, backendId, model) {
145
+ const installer = BACKEND_INSTALLERS[backendId];
146
+ if (!(await ensureHomebrewFor(prompt, run, installer.label))) return;
147
+ console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
148
+ try {
149
+ await runInstallerCommands(run, installer);
150
+ installer.success(model);
151
+ } catch {
152
+ console.log(pc.red(`✗ ${installer.label} installation failed.`));
153
+ console.log(pc.dim(installer.failure));
154
+ }
155
+ }
156
+
157
+ async function installAllBackends(prompt, run, model) {
158
+ if (!(await ensureHomebrewFor(prompt, run, "model backends"))) return;
159
+ const installed = [];
160
+ for (const installer of Object.values(BACKEND_INSTALLERS)) {
161
+ console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
162
+ try {
163
+ await runInstallerCommands(run, installer);
164
+ installed.push(installer.label);
165
+ } catch {
166
+ console.log(pc.yellow(installer.allFailure));
167
+ }
168
+ }
169
+ if (installed.length > 0) {
170
+ console.log(pc.green(`\n✓ Installed: ${installed.join(", ")}`));
171
+ console.log(pc.dim(`Recommended for your machine (${installedRamGB()}GB RAM): ${model.label}`));
172
+ }
173
+ }
174
+
175
+ async function runInstallerCommands(run, installer) {
176
+ for (const [cmd, args, label] of installer.commands) await run(cmd, args, label);
177
+ }
@@ -0,0 +1,146 @@
1
+ import { existsSync } from "node:fs";
2
+ import { ensureDirs } from "../config.mjs";
3
+ import { backendFor } from "../backends.mjs";
4
+ import { normalizeProfile, readProfile, saveProfile } from "../profiles.mjs";
5
+ import { startServer, stopProfile, waitForReady, serverReady, serverMatchesProfile } from "../process.mjs";
6
+ import { syncPiConfig, hasPiModel, launchPi, hasPi } from "../harness-pi.mjs";
7
+ import { tailFriendly } from "../logs.mjs";
8
+ import { estimateMemory } from "../estimate.mjs";
9
+ import { pc, formatBytes, renderRows, renderSection, parseOptions } from "../ui.mjs";
10
+
11
+ export async function runCommand(argv) {
12
+ await ensureDirs();
13
+ const { positional, options } = parseOptions(argv);
14
+ if (!positional[0]) {
15
+ const { mainFlow } = await import("./main.mjs");
16
+ return await mainFlow();
17
+ }
18
+ return await runProfile(await readProfile(positional[0]), options);
19
+ }
20
+
21
+ export async function runProfile(profile, options = {}) {
22
+ const backend = backendFor(profile.backend);
23
+ const withHarness = options.with ?? "pi";
24
+
25
+ if (withHarness === "pi" && !(await hasPi())) {
26
+ console.log(pc.yellow("Pi is not installed. Run with --with server, or install Pi from https://pi.app"));
27
+ console.log(pc.dim("Starting server only..."));
28
+ return await runProfile(profile, { ...options, with: "server" });
29
+ }
30
+
31
+ const isManaged = backend.type === "managed-server";
32
+ if (isManaged) {
33
+ if (!(await serverReady(profile.baseUrl))) {
34
+ throw new Error(`${backend.label} is not running at ${profile.baseUrl}. Start it and try again.`);
35
+ }
36
+ console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
37
+ } else {
38
+ const startup = await ensureLocalServer(profile, backend, options);
39
+ if (startup?.handled) return startup.result;
40
+ }
41
+
42
+ printMemoryEstimate(profile, isManaged);
43
+ await launchHarness(profile, options, isManaged, withHarness, backend);
44
+ }
45
+
46
+ async function ensureLocalServer(profile, backend, options) {
47
+ if (await serverReady(profile.baseUrl)) {
48
+ const match = await serverMatchesProfile(profile);
49
+ if (!match.matches) {
50
+ 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.`);
51
+ }
52
+ console.log(pc.green(`[ready] Reusing server at ${profile.baseUrl}`));
53
+ return;
54
+ }
55
+
56
+ console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
57
+ let state;
58
+ try {
59
+ state = await startServer(profile);
60
+ const tail = state?.rawLogPath ? tailFriendly(state.rawLogPath, state.friendlyLogPath) : { stop() {} };
61
+ try {
62
+ await waitForReady(profile, state?.pid, state?.rawLogPath);
63
+ console.log(pc.green(`[ready] ${profile.baseUrl}/models`));
64
+ } finally {
65
+ tail.stop();
66
+ }
67
+ } catch (err) {
68
+ if (state?.pid) {
69
+ try { await stopProfile(profile); } catch { /* best effort */ }
70
+ }
71
+ if (!options.textOnlyRetry && isUnsupportedMmprojError(err, profile)) {
72
+ console.log(pc.yellow("Vision projector is not supported by this llama.cpp build. Retrying text-only."));
73
+ console.log(pc.dim("Update llama.cpp later to re-enable vision for this model."));
74
+ const textOnly = textOnlyProfile(profile);
75
+ await saveProfile(textOnly, { writeCommand: true });
76
+ return { handled: true, result: await runProfile(textOnly, { ...options, textOnlyRetry: true }) };
77
+ }
78
+ throw err;
79
+ }
80
+ }
81
+
82
+ function printMemoryEstimate(profile, isManaged) {
83
+ if (isManaged || !profile.modelPath || !existsSync(profile.modelPath)) return;
84
+ try {
85
+ const est = estimateMemory(profile.modelPath, profile.mmprojPath, profile.drafterPath, profile.flags);
86
+ const rows = [
87
+ ["Estimated total", pc.bold(`~${formatBytes(est.totalBytes)}`)],
88
+ ["Model file", formatBytes(est.modelBytes)],
89
+ ];
90
+ if (est.draftBytes) rows.push(["Drafter", formatBytes(est.draftBytes)]);
91
+ if (est.mmprojBytes) rows.push(["Vision projector", formatBytes(est.mmprojBytes)]);
92
+ rows.push(["Conversation memory", est.kvBytes ? `~${formatBytes(est.kvBytes)}` : "unknown"]);
93
+ console.log(renderSection("Memory estimate", renderRows(rows)));
94
+ } catch {
95
+ // Memory estimates are informational only.
96
+ }
97
+ }
98
+
99
+ async function launchHarness(profile, options, isManaged, withHarness, backend) {
100
+ if (withHarness !== "pi") {
101
+ if (!isManaged) {
102
+ console.log(pc.dim(`Server running at ${profile.baseUrl}`));
103
+ console.log(pc.dim(`Stop with: offgrid-ai stop ${profile.id}`));
104
+ } else {
105
+ console.log(pc.dim(`${backend.label} is a managed service — offgrid-ai does not stop it.`));
106
+ }
107
+ return;
108
+ }
109
+
110
+ if (!(await hasPiModel(profile))) await syncPiConfig(profile);
111
+ try {
112
+ await launchPi(profile);
113
+ } finally {
114
+ if (!isManaged && !options["keep-server"]) {
115
+ const result = await stopProfile(profile);
116
+ console.log(result.stopped ? pc.green(`[stop] ${result.message}`) : pc.dim(`[stop] ${result.message}`));
117
+ }
118
+ }
119
+ }
120
+
121
+ function isUnsupportedMmprojError(err, profile) {
122
+ const message = String(err?.message ?? "");
123
+ return Boolean(profile.mmprojPath && /unknown projector type|failed to load multimodal model|failed to load CLIP model/i.test(message));
124
+ }
125
+
126
+ function textOnlyProfile(profile) {
127
+ return normalizeProfile({
128
+ ...profile,
129
+ mmprojPath: null,
130
+ disabledMmprojPath: profile.disabledMmprojPath ?? profile.mmprojPath,
131
+ capabilities: { ...(profile.capabilities ?? {}), vision: false, visionDisabledReason: "unsupported-mmproj" },
132
+ commandArgv: removeCommandOption(profile.commandArgv ?? [], "--mmproj"),
133
+ });
134
+ }
135
+
136
+ function removeCommandOption(argv, flag) {
137
+ const next = [];
138
+ for (let i = 0; i < argv.length; i++) {
139
+ if (argv[i] === flag) {
140
+ if (argv[i + 1] && !argv[i + 1].startsWith("--")) i += 1;
141
+ continue;
142
+ }
143
+ next.push(argv[i]);
144
+ }
145
+ return next;
146
+ }
@@ -0,0 +1,38 @@
1
+ import { ensureDirs } from "../config.mjs";
2
+ import { backendFor } from "../backends.mjs";
3
+ import { loadProfiles } from "../profiles.mjs";
4
+ import { profileRuntimeStatus } from "../process.mjs";
5
+ import { pc, renderRows, renderCard } from "../ui.mjs";
6
+
7
+ export async function statusCommand() {
8
+ await ensureDirs();
9
+ const profiles = await loadProfiles();
10
+ const statuses = [];
11
+ for (const profile of profiles) {
12
+ statuses.push({ profile, status: await profileRuntimeStatus(profile) });
13
+ }
14
+
15
+ const running = statuses.filter((item) => item.status.running);
16
+ if (running.length === 0) {
17
+ console.log(renderCard("Status", renderRows([
18
+ ["Running now", pc.dim("none")],
19
+ ["Ready setups", profiles.length > 0 ? String(profiles.length) : pc.dim("none")],
20
+ ["Next step", profiles.length > 0 ? "Run offgrid-ai to start chatting" : pc.yellow("Run offgrid-ai to set up a model")],
21
+ ]), { formatBorder: pc.dim }));
22
+ return;
23
+ }
24
+
25
+ console.log(renderCard("Status", renderRows([
26
+ ["Running now", pc.green(`${running.length} model${running.length === 1 ? "" : "s"}`)],
27
+ ["Stop", "offgrid-ai stop"],
28
+ ]), { formatBorder: pc.green }));
29
+ for (const { profile, status } of running) {
30
+ const backend = backendFor(profile.backend);
31
+ console.log("\n" + renderCard(profile.label, renderRows([
32
+ ["Status", status.ready ? pc.green("Ready") : pc.yellow("Starting up")],
33
+ ["Runs with", backend.label],
34
+ ["Process", `pid ${status.pid}`],
35
+ ["Server", profile.baseUrl],
36
+ ]), { formatBorder: status.ready ? pc.green : pc.yellow }));
37
+ }
38
+ }