offgrid-ai 0.5.0 → 0.5.2
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/package.json +1 -1
- package/src/cli.mjs +110 -52
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -172,54 +172,49 @@ async function modelsCommand(argv) {
|
|
|
172
172
|
return await modelCommandCenter(catalog);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
async function modelCommandCenter(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
async function modelCommandCenter(initialCatalog) {
|
|
176
|
+
if (!process.stdin.isTTY) {
|
|
177
|
+
const normalized = normalizeCatalog(initialCatalog);
|
|
178
|
+
const allItems = buildCatalogItems(normalized);
|
|
179
|
+
if (allItems.length === 0) return;
|
|
180
|
+
for (const item of allItems) console.log(item.label);
|
|
181
|
+
return;
|
|
181
182
|
}
|
|
182
|
-
const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
183
|
-
|
|
184
|
-
// Summary card
|
|
185
|
-
const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
|
|
186
|
-
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
187
|
-
["Setups", `${profiles.length} saved`],
|
|
188
|
-
["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
189
|
-
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
190
|
-
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
191
|
-
]), { formatBorder: summaryBorder }));
|
|
192
|
-
|
|
193
|
-
if (!process.stdin.isTTY) return;
|
|
194
|
-
const allItems = buildCatalogItems(normalized);
|
|
195
|
-
if (allItems.length === 0) return;
|
|
196
183
|
|
|
197
184
|
const prompt = createPrompt();
|
|
198
185
|
try {
|
|
199
186
|
while (true) {
|
|
200
|
-
|
|
187
|
+
const catalog = await loadModelCatalog();
|
|
188
|
+
const normalized = normalizeCatalog(catalog);
|
|
189
|
+
const allItems = buildCatalogItems(normalized);
|
|
190
|
+
if (allItems.length === 0) { console.log(pc.dim("No models found.")); break; }
|
|
191
|
+
const runningProfilesNow = [];
|
|
192
|
+
for (const profile of normalized.profiles) {
|
|
193
|
+
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
194
|
+
}
|
|
195
|
+
const fileMissingCount = normalized.profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
196
|
+
const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
|
|
197
|
+
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
198
|
+
["Setups", `${normalized.profiles.length} saved`],
|
|
199
|
+
["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
200
|
+
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
201
|
+
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
202
|
+
]), { formatBorder: summaryBorder }));
|
|
203
|
+
|
|
201
204
|
const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
|
|
202
205
|
const selected = await prompt.choice("Select a model", options);
|
|
203
206
|
if (!selected) break;
|
|
204
207
|
const item = allItems.find((i) => itemKey(i) === selected);
|
|
205
208
|
if (!item) break;
|
|
206
209
|
|
|
207
|
-
//
|
|
210
|
+
// Show detail card before action menu
|
|
211
|
+
console.log("");
|
|
212
|
+
printItemCard(item, { runningProfilesNow, drafters: normalized.drafters });
|
|
213
|
+
|
|
208
214
|
const actions = actionsForItem(item);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
} else {
|
|
213
|
-
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
214
|
-
if (!action) continue;
|
|
215
|
-
if (action === "back") continue;
|
|
216
|
-
await performAction(prompt, action, item);
|
|
217
|
-
}
|
|
218
|
-
// After action, refresh running state and loop back
|
|
219
|
-
runningProfilesNow.length = 0;
|
|
220
|
-
for (const profile of profiles) {
|
|
221
|
-
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
222
|
-
}
|
|
215
|
+
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
216
|
+
if (!action) continue;
|
|
217
|
+
await performAction(prompt, action, item);
|
|
223
218
|
}
|
|
224
219
|
} finally {
|
|
225
220
|
prompt.close();
|
|
@@ -285,31 +280,32 @@ function modelSelectOption(item, { runningProfilesNow }) {
|
|
|
285
280
|
const { profile } = item;
|
|
286
281
|
const running = runningProfilesNow.some((r) => r.id === profile.id);
|
|
287
282
|
const fileMissing = item.fileMissing;
|
|
288
|
-
const caps = profile.capabilities ?? {};
|
|
289
283
|
let status;
|
|
290
|
-
if (fileMissing) status = pc.red("
|
|
291
|
-
else if (running) status = pc.green("
|
|
292
|
-
else status = pc.dim("
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (profile.drafterPath)
|
|
296
|
-
if (profile.
|
|
297
|
-
|
|
284
|
+
if (fileMissing) status = pc.red("⚠ missing");
|
|
285
|
+
else if (running) status = pc.green("● running");
|
|
286
|
+
else status = pc.dim("● ready");
|
|
287
|
+
// MTP label: enabled vs available vs not available
|
|
288
|
+
let mtpLabel;
|
|
289
|
+
if (profile.drafterPath) mtpLabel = pc.green("MTP");
|
|
290
|
+
else if ((profile.capabilities?.architecture === "gemma4")) mtpLabel = pc.yellow("MTP available");
|
|
291
|
+
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k` : null;
|
|
292
|
+
const label = [profile.label, status, mtpLabel, ctxLabel].filter(Boolean).join(" ");
|
|
293
|
+
return { value: itemKey(item), label, hint: humanCapabilitySummary(profile.capabilities ?? {}) || profile.modelAlias };
|
|
298
294
|
}
|
|
299
295
|
if (item.type === "new") {
|
|
300
296
|
const { model, drafter } = item;
|
|
301
297
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
if (
|
|
305
|
-
else if (caps.architecture === "gemma4")
|
|
306
|
-
|
|
307
|
-
return { value: itemKey(item), label
|
|
298
|
+
const mtpAvailable = caps.mtp || Boolean(drafter);
|
|
299
|
+
let mtpLabel;
|
|
300
|
+
if (mtpAvailable) mtpLabel = pc.green("MTP ✓");
|
|
301
|
+
else if (caps.architecture === "gemma4") mtpLabel = pc.yellow("MTP");
|
|
302
|
+
const label = [model.label, pc.yellow("needs setup"), mtpLabel, formatBytes(model.sizeBytes)].filter(Boolean).join(" ");
|
|
303
|
+
return { value: itemKey(item), label, hint: humanCapabilitySummary(caps) || model.quant };
|
|
308
304
|
}
|
|
309
305
|
// managed
|
|
310
306
|
const { model, backendId } = item;
|
|
311
307
|
const backend = BACKENDS[backendId];
|
|
312
|
-
return { value: itemKey(item), label: model.label
|
|
308
|
+
return { value: itemKey(item), label: `${model.label} ${pc.dim(`via ${backend.label}`)}`, hint: model.id };
|
|
313
309
|
}
|
|
314
310
|
|
|
315
311
|
function actionsForItem(item) {
|
|
@@ -344,6 +340,68 @@ function isProfileFileMissing(profile) {
|
|
|
344
340
|
return !existsSync(profile.modelPath);
|
|
345
341
|
}
|
|
346
342
|
|
|
343
|
+
function printItemCard(item, { runningProfilesNow, drafters }) {
|
|
344
|
+
if (item.type === "profile") {
|
|
345
|
+
const { profile } = item;
|
|
346
|
+
const backend = backendFor(profile.backend);
|
|
347
|
+
const isManaged = backend.type === "managed-server";
|
|
348
|
+
const running = runningProfilesNow.some((r) => r.id === profile.id);
|
|
349
|
+
const fileMissing = isProfileFileMissing(profile);
|
|
350
|
+
const caps = profile.capabilities ?? {};
|
|
351
|
+
let status;
|
|
352
|
+
if (fileMissing) status = pc.red("File missing");
|
|
353
|
+
else if (running) status = pc.green("Running now");
|
|
354
|
+
else status = "Ready";
|
|
355
|
+
const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
356
|
+
// MTP: distinguish enabled vs available
|
|
357
|
+
let mtpLabel;
|
|
358
|
+
if (profile.drafterPath) {
|
|
359
|
+
mtpLabel = pc.green(`enabled (drafter: ${basename(profile.drafterPath)})`);
|
|
360
|
+
} else if (drafters ? matchDrafter(profile.modelPath, drafters) : null) {
|
|
361
|
+
mtpLabel = pc.yellow("available — reconfigure to enable");
|
|
362
|
+
} else if (caps.architecture === "gemma4") {
|
|
363
|
+
mtpLabel = pc.yellow("drafter not found");
|
|
364
|
+
}
|
|
365
|
+
const detailParts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps)];
|
|
366
|
+
if (mtpLabel) detailParts.push(mtpLabel);
|
|
367
|
+
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
368
|
+
if (ctxLabel) detailParts.push(ctxLabel);
|
|
369
|
+
const rows = [
|
|
370
|
+
["Status", status],
|
|
371
|
+
["Details", detailParts.join(pc.dim(" · "))],
|
|
372
|
+
["Runs with", backend.label],
|
|
373
|
+
];
|
|
374
|
+
if (!isManaged && profile.modelPath) {
|
|
375
|
+
rows.push(["File", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath]);
|
|
376
|
+
}
|
|
377
|
+
console.log(renderCard(profile.label, renderRows(rows), { formatBorder: border }));
|
|
378
|
+
} else if (item.type === "new") {
|
|
379
|
+
const { model, drafter } = item;
|
|
380
|
+
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
381
|
+
const mtpAvailable = caps.mtp || Boolean(drafter);
|
|
382
|
+
const mtpLabel = mtpAvailable
|
|
383
|
+
? pc.green("MTP ✓")
|
|
384
|
+
: (caps.architecture === "gemma4")
|
|
385
|
+
? pc.yellow("MTP: needs drafter")
|
|
386
|
+
: null;
|
|
387
|
+
const detailParts = [humanCapabilitySummary(caps)];
|
|
388
|
+
if (mtpLabel) detailParts.push(mtpLabel);
|
|
389
|
+
detailParts.push(formatBytes(model.sizeBytes));
|
|
390
|
+
console.log(renderCard(model.label, renderRows([
|
|
391
|
+
["Status", pc.yellow("Needs setup")],
|
|
392
|
+
["Details", detailParts.join(pc.dim(" · "))],
|
|
393
|
+
]), { formatBorder: pc.yellow }));
|
|
394
|
+
} else {
|
|
395
|
+
// managed
|
|
396
|
+
const { model, backendId } = item;
|
|
397
|
+
const backend = BACKENDS[backendId];
|
|
398
|
+
console.log(renderCard(model.label, renderRows([
|
|
399
|
+
["Status", pc.dim(`Via ${backend.label}`)],
|
|
400
|
+
["Details", [model.id, model.quant].filter(Boolean).join(pc.dim(" · "))],
|
|
401
|
+
]), { formatBorder: pc.dim }));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
347
405
|
async function performAction(prompt, action, item) {
|
|
348
406
|
if (action === "inspect") {
|
|
349
407
|
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|