offgrid-ai 0.1.2 → 0.2.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.mjs +194 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
package/src/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
- import { existsSync, statSync } from "node:fs";
2
+ import { existsSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { ensureDirs, findLlamaServer, hasHomebrew } from "./config.mjs";
4
+ import { ensureDirs, findLlamaServer, hasHomebrew, DATA_DIR } from "./config.mjs";
5
5
  import { scanGgufModels } from "./scan.mjs";
6
6
  import { createProfileFromModel, normalizeProfile } from "./profiles.mjs";
7
7
  import { readProfile, saveProfile, deleteProfile, loadProfiles } from "./profiles.mjs";
@@ -22,6 +22,7 @@ export async function run(argv) {
22
22
  if (command === "version" || command === "--version" || command === "-v") return printVersion();
23
23
  if (command === "status") return statusCommand();
24
24
  if (command === "stop") return stopCommand(argv.slice(1));
25
+ if (command === "uninstall" || command === "--uninstall") return uninstallCommand(argv.slice(1));
25
26
 
26
27
  throw new Error(`Unknown command: ${command}. Run offgrid-ai help`);
27
28
  }
@@ -509,18 +510,46 @@ async function onboardFlow() {
509
510
  // 1. Homebrew
510
511
  const hasBrew = await hasHomebrew();
511
512
  if (!hasBrew) {
512
- const install = await prompt.yesNo("Homebrew is required. Install it?", true);
513
- if (!install) { console.log(pc.red("offgrid-ai needs Homebrew. Install it from https://brew.sh")); return; }
514
- console.log(pc.dim("Install Homebrew from https://brew.sh, then run offgrid-ai again."));
515
- return;
513
+ const install = await prompt.yesNo("Homebrew is required. Install it now?", true);
514
+ if (!install) {
515
+ console.log(pc.red("offgrid-ai needs Homebrew to install dependencies."));
516
+ console.log(pc.dim("Install it from https://brew.sh, then run offgrid-ai again."));
517
+ return;
518
+ }
519
+ console.log(pc.cyan("Installing Homebrew..."));
520
+ const { execFile } = await import("node:child_process");
521
+ const { promisify } = await import("node:util");
522
+ try {
523
+ await promisify(execFile)("/bin/bash", ["-c", "NONINTERACTIVE=1 /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""], { stdio: "inherit" });
524
+ // Add brew to PATH for this session
525
+ const brewPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
526
+ for (const p of brewPaths) {
527
+ if (existsSync(p)) {
528
+ process.env.PATH = `${p}:${process.env.PATH}`;
529
+ break;
530
+ }
531
+ }
532
+ } catch (err) {
533
+ console.log(pc.red(`Homebrew installation failed: ${err.message}`));
534
+ console.log(pc.dim("Install it manually from https://brew.sh, then run offgrid-ai again."));
535
+ return;
536
+ }
537
+ if (!(await hasHomebrew())) {
538
+ console.log(pc.red("Homebrew was installed but not found on PATH. Restart your terminal and run offgrid-ai again."));
539
+ return;
540
+ }
516
541
  }
517
542
  console.log(pc.green("✓ Homebrew found"));
518
543
 
519
544
  // 2. llama-server
520
545
  let llamaBinary = await findLlamaServer();
521
546
  if (!llamaBinary) {
522
- const install = await prompt.yesNo("llama-server is required. Install via Homebrew?", true);
523
- if (!install) { console.log(pc.red("offgrid-ai needs llama-server to run local models.")); return; }
547
+ const install = await prompt.yesNo("llama-server is required to run local models. Install via Homebrew?", true);
548
+ if (!install) {
549
+ console.log(pc.red("offgrid-ai needs llama-server to run local models."));
550
+ console.log(pc.dim("Install it manually: brew install llama.cpp"));
551
+ return;
552
+ }
524
553
  console.log(pc.cyan("Installing llama.cpp..."));
525
554
  const { execFile } = await import("node:child_process");
526
555
  const { promisify } = await import("node:util");
@@ -528,39 +557,176 @@ async function onboardFlow() {
528
557
  await promisify(execFile)("brew", ["install", "llama.cpp"], { stdio: "inherit" });
529
558
  llamaBinary = await findLlamaServer();
530
559
  } catch (err) {
531
- console.log(pc.red(`Failed: ${err.message}`));
560
+ console.log(pc.red(`Failed to install llama.cpp: ${err.message}`));
561
+ return;
562
+ }
563
+ if (!llamaBinary) {
564
+ console.log(pc.yellow("llama.cpp installed but llama-server not found. You may need to restart your terminal."));
532
565
  return;
533
566
  }
534
567
  }
535
568
  console.log(pc.green(`✓ llama-server: ${llamaBinary}`));
536
569
 
537
- // 3. Check for models
570
+ // 3. Model backends — at least one is mandatory
538
571
  const ggufModels = await scanGgufModels();
539
572
  const managedModels = await scanManagedModels();
540
573
  const totalManaged = managedModels.reduce((sum, m) => sum + m.models.length, 0);
574
+ const hasModels = ggufModels.length > 0 || totalManaged > 0;
541
575
 
542
- if (ggufModels.length > 0) {
543
- console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"} in LM Studio`));
576
+ if (hasModels) {
577
+ // They already have models show what was found
578
+ if (ggufModels.length > 0) {
579
+ console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
580
+ }
581
+ for (const { backendId, models } of managedModels) {
582
+ if (models.length > 0) {
583
+ console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
584
+ }
585
+ }
586
+ } else {
587
+ // No models found — offer to install backends that come with models
588
+ console.log(pc.yellow("\nNo models found."));
589
+ console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
590
+
591
+ const backendChoice = await prompt.choice("Install a model backend?", [
592
+ { value: "ollama", label: "Ollama", hint: "Easiest way — models download on demand" },
593
+ { value: "lmstudio", label: "LM Studio", hint: "Visual model browser (opens download page)" },
594
+ { value: "omlx", label: "oMLX", hint: "Apple Silicon optimized" },
595
+ { value: "skip", label: "Skip for now", hint: "I'll set up models myself" },
596
+ ], "ollama");
597
+
598
+ if (backendChoice === "ollama") {
599
+ console.log(pc.cyan("Installing Ollama via Homebrew..."));
600
+ const { execFile } = await import("node:child_process");
601
+ const { promisify } = await import("node:util");
602
+ try {
603
+ await promisify(execFile)("brew", ["install", "ollama"], { stdio: "inherit" });
604
+ console.log(pc.green("✓ Ollama installed"));
605
+ console.log(pc.cyan("\nStarting Ollama..."));
606
+ try {
607
+ await promisify(execFile)("ollama", ["serve"], { stdio: "ignore", detached: true });
608
+ // Give it a moment to start
609
+ await new Promise((resolve) => setTimeout(resolve, 2000));
610
+ } catch { /* may already be running */ }
611
+ console.log(pc.green("Ollama is running."));
612
+ console.log(pc.yellow("\nPull your first model:"));
613
+ console.log(pc.bold(" ollama pull gemma3:4b"));
614
+ console.log(pc.dim("Then run offgrid-ai again to pick and run a model."));
615
+ } catch (err) {
616
+ console.log(pc.red(`Failed to install Ollama: ${err.message}`));
617
+ console.log(pc.dim("Install it manually from https://ollama.com"));
618
+ }
619
+ } else if (backendChoice === "lmstudio") {
620
+ console.log(pc.cyan("LM Studio needs to be installed manually."));
621
+ console.log(pc.bold("\n Download LM Studio: https://lmstudio.ai"));
622
+ console.log(pc.dim("Then browse and download models inside LM Studio, and run offgrid-ai again."));
623
+ } else if (backendChoice === "omlx") {
624
+ console.log(pc.cyan("Installing oMLX via pip..."));
625
+ const { execFile } = await import("node:child_process");
626
+ const { promisify } = await import("node:util");
627
+ try {
628
+ await promisify(execFile)("pip3", ["install", "omlx"], { stdio: "inherit" });
629
+ console.log(pc.green("✓ oMLX installed"));
630
+ console.log(pc.yellow("\nStart oMLX server:"));
631
+ console.log(pc.bold(" omlx serve"));
632
+ console.log(pc.dim("Then run offgrid-ai again to pick and run a model."));
633
+ } catch (err) {
634
+ console.log(pc.red(`Failed to install oMLX: ${err.message}`));
635
+ console.log(pc.dim("Install it manually: pip3 install omlx"));
636
+ }
637
+ } else {
638
+ console.log(pc.dim("Run offgrid-ai again when you've set up a model backend."));
639
+ }
640
+ return;
544
641
  }
545
- for (const { backendId, models } of managedModels) {
546
- if (models.length > 0) {
547
- console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
642
+
643
+ console.log(pc.green("\n✓ Setup complete! Run offgrid-ai to pick and run a model."));
644
+ } finally {
645
+ prompt.close();
646
+ }
647
+ }
648
+
649
+ // ── Uninstall ───────────────────────────────────────────────────────────────
650
+
651
+ async function uninstallCommand(argv) {
652
+ if (!process.stdin.isTTY) {
653
+ // Non-interactive: remove everything
654
+ await removeDataDir();
655
+ removeSelf();
656
+ return;
657
+ }
658
+
659
+ startInteractive("offgrid-ai uninstall");
660
+ const prompt = createPrompt();
661
+ try {
662
+ console.log(pc.bold("offgrid-ai uninstall\n"));
663
+
664
+ // Stop any running servers first
665
+ const running = await runningProfiles();
666
+ if (running.length > 0) {
667
+ console.log(pc.yellow(`${running.length} server(s) still running. Stopping...`));
668
+ for (const { profile } of running) {
669
+ await stopProfile(profile);
548
670
  }
671
+ console.log(pc.green("All servers stopped."));
549
672
  }
550
673
 
551
- if (ggufModels.length === 0 && totalManaged === 0) {
552
- console.log(pc.yellow("\nNo models found. Download one in LM Studio, start Ollama, or install oMLX."));
553
- console.log(pc.dim(" LM Studio: https://lmstudio.ai"));
554
- console.log(pc.dim(" Then run offgrid-ai again."));
555
- return;
674
+ // Ask about data
675
+ const dataDir = DATA_DIR;
676
+ const keepData = await prompt.yesNo("Keep your profiles and model configurations? (Recommended if you plan to reinstall)", true);
677
+
678
+ if (!keepData) {
679
+ const confirmDelete = await prompt.yesNo(`Delete ${dataDir}? This removes all profiles and settings.`, false);
680
+ if (confirmDelete) {
681
+ await removeDataDir();
682
+ } else {
683
+ console.log(pc.dim("Keeping data directory."));
684
+ }
685
+ } else {
686
+ console.log(pc.dim(`Keeping ${dataDir} for when you reinstall.`));
556
687
  }
557
688
 
558
- console.log(pc.green("\nSetup complete! Run offgrid-ai to pick and run a model."));
689
+ // Remove the npm package
690
+ const confirmUninstall = await prompt.yesNo("Uninstall offgrid-ai npm package?", true);
691
+ if (confirmUninstall) {
692
+ removeSelf();
693
+ } else {
694
+ console.log(pc.dim("Cancelled."));
695
+ }
559
696
  } finally {
560
697
  prompt.close();
561
698
  }
562
699
  }
563
700
 
701
+ async function removeDataDir() {
702
+ const dataDir = DATA_DIR;
703
+ if (existsSync(dataDir)) {
704
+ try {
705
+ rmSync(dataDir, { recursive: true, force: true });
706
+ console.log(pc.green(`✓ Removed ${dataDir}`));
707
+ } catch (err) {
708
+ console.log(pc.red(`Failed to remove ${dataDir}: ${err.message}`));
709
+ console.log(pc.dim(`Remove it manually: rm -rf ${dataDir}`));
710
+ }
711
+ } else {
712
+ console.log(pc.dim(`${dataDir} doesn't exist — already clean.`));
713
+ }
714
+ }
715
+
716
+ async function removeSelf() {
717
+ console.log(pc.cyan("\nUninstalling offgrid-ai..."));
718
+ const { execFile } = await import("node:child_process");
719
+ const { promisify } = await import("node:util");
720
+ try {
721
+ await promisify(execFile)("npm", ["uninstall", "-g", "offgrid-ai"], { stdio: "inherit" });
722
+ console.log(pc.green("\n✓ offgrid-ai has been uninstalled."));
723
+ console.log(pc.dim("Reinstall anytime with: npm install -g offgrid-ai"));
724
+ } catch {
725
+ console.log(pc.red("\nCould not auto-uninstall. Run this manually:"));
726
+ console.log(pc.bold(" npm uninstall -g offgrid-ai"));
727
+ }
728
+ }
729
+
564
730
  // ── Helpers ─────────────────────────────────────────────────────────────────
565
731
 
566
732
  async function scanManagedModels() {
@@ -592,12 +758,13 @@ function printHelp() {
592
758
  console.log(`${pc.bold("offgrid-ai")} — privacy-first local LLM runner
593
759
 
594
760
  Usage:
595
- offgrid-ai Pick a model and run it
596
- offgrid-ai status Show running local models
597
- offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
598
- offgrid-ai help Show this help
599
- offgrid-ai version Show version
761
+ offgrid-ai Pick a model and run it
762
+ offgrid-ai status Show running local models
763
+ offgrid-ai stop Stop a running server (or: offgrid-ai stop <id>)
764
+ offgrid-ai uninstall Remove offgrid-ai (optionally keep profiles)
765
+ offgrid-ai help Show this help
766
+ offgrid-ai version Show version
600
767
 
601
768
  First run? offgrid-ai walks you through installing everything you need.
602
769
  After that, just run it — it finds your models, auto-configures, and launches Pi.`);
603
- }
770
+ }