Repository: marimo-team/marimo
Pull request: improvement: better prompt-to-install when missing deps, grouped installed, Optional Features panel
Fixes #3113 

<img width="1293" alt="Screenshot 2025-03-12 at 9 30 17 PM" src="https://github.com/user-attachments/assets/dc817d7a-7430-49d4-a9fa-3e0378c4c594" />

* Remove 'SQL' from KernelCapabilities. If the user tries to run SQL, we will not prompt to install the necessary deps.
* We can now prompt the user for multiple missing deps
* Update `ai` endpoints to throw missing modules so that we can prompt the user as well.
* `Optional Dependencies` panel in the settings menu to show the enabled features and install. This is both for education and ease of installation.

I didn't want to put everything in `KernelCapabilities` since its another source of truth, and requires checking it on startup, which could be a waste of resources/blocking

Problem statement:
Easier installation of optional dependencies for the marimo editor
### Description

Easier installation of optional dependencies that enable: 
- SQL support (duckdb)
- AI features (openai/anthropic)
- Charting (altair)
- Charting w/ performance (vega-fusion)


### Suggested solution

We can re-use the Package installation modal that happens when there are imports that fail. We will also need to make sure the UI is updated appropriately (`capabilities` is refreshed).

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:

Commit message: type check

Patch to review:
diff --git a/frontend/src/components/app-config/common.tsx b/frontend/src/components/app-config/common.tsx
index ae20242e16e..6267f74f478 100644
--- a/frontend/src/components/app-config/common.tsx
+++ b/frontend/src/components/app-config/common.tsx
@@ -19,7 +19,7 @@ export const SettingSubtitle: React.FC<HTMLProps<HTMLDivElement>> = ({
     <div
       {...props}
       className={cn(
-        "text-sm font-semibold underline-offset-2 text-accent-foreground uppercase tracking-wide",
+        "text-base font-semibold underline-offset-2 text-accent-foreground uppercase tracking-wide",
         className,
       )}
     >
diff --git a/frontend/src/components/app-config/optional-features.tsx b/frontend/src/components/app-config/optional-features.tsx
new file mode 100644
index 00000000000..6b357a1dbc2
--- /dev/null
+++ b/frontend/src/components/app-config/optional-features.tsx
@@ -0,0 +1,229 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import React from "react";
+import { BoxIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
+import { useAsyncData } from "@/hooks/useAsyncData";
+import { useResolvedMarimoConfig } from "@/core/config/config";
+import { addPackage, getPackageList } from "@/core/network/requests";
+import { ErrorBanner } from "@/plugins/impl/common/error-banner";
+import { Spinner } from "@/components/icons/spinner";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { toast } from "@/components/ui/use-toast";
+import { Kbd } from "@/components/ui/kbd";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/cn";
+import { SettingSubtitle } from "./common";
+
+interface Package {
+  name: string;
+  minVersion?: string;
+}
+
+interface OptionalFeature {
+  id: string;
+  /**
+   * Required packages to install for the feature to work.
+   */
+  packagesRequired: Package[];
+  /**
+   * Additional packages to install if installed through this UI.
+   */
+  additionalPackageInstalls: Package[];
+  /**
+   * Description of the feature.
+   */
+  description: string;
+}
+
+// Define the optional dependencies and their features
+const OPTIONAL_DEPENDENCIES: OptionalFeature[] = [
+  {
+    id: "sql",
+    packagesRequired: [{ name: "duckdb" }, { name: "sqlglot" }],
+    additionalPackageInstalls: [{ name: "polars[pyarrow]" }],
+    description: "SQL cells",
+  },
+  {
+    id: "charts",
+    packagesRequired: [{ name: "altair" }],
+    additionalPackageInstalls: [],
+    description: "Charts in datasource viewer",
+  },
+  {
+    id: "fast-charts",
+    packagesRequired: [{ name: "vegafusion" }, { name: "vl-convert-python" }],
+    additionalPackageInstalls: [],
+    description: "Fast server-side charts",
+  },
+  {
+    id: "formatting",
+    packagesRequired: [{ name: "ruff" }],
+    additionalPackageInstalls: [],
+    description: "Formatting",
+  },
+  {
+    id: "ai",
+    packagesRequired: [{ name: "openai" }],
+    additionalPackageInstalls: [],
+    description: "AI features",
+  },
+  {
+    id: "ipy-export",
+    packagesRequired: [{ name: "nbformat" }],
+    additionalPackageInstalls: [],
+    description: "Export as IPYNB",
+  },
+];
+
+export const OptionalFeatures: React.FC = () => {
+  const [config] = useResolvedMarimoConfig();
+  const packageManager = config.package_management.manager;
+  const { data, loading, error, reload } = useAsyncData(
+    () => getPackageList(),
+    [packageManager],
+  );
+
+  if (loading && !data) {
+    return <Spinner size="medium" centered={true} />;
+  }
+
+  if (error) {
+    return <ErrorBanner error={error} />;
+  }
+
+  const installedPackages = data?.packages || [];
+  const installedPackageNames = new Set(
+    installedPackages.map((pkg) => pkg.name),
+  );
+
+  return (
+    <div className="flex-1 flex flex-col overflow-hidden gap-2">
+      <SettingSubtitle>Optional Features</SettingSubtitle>
+      <p className="text-sm text-muted-foreground">
+        marimo is lightweight, with few dependencies, to maximize compatibility
+        with your own environments.
+        <br />
+        To unlock additional features in the marimo editor, you can install
+        these optional dependencies:
+      </p>
+      <Table>
+        <TableHeader>
+          <TableRow>
+            <TableHead>Dependency</TableHead>
+            <TableHead>Feature</TableHead>
+            <TableHead>Status</TableHead>
+            <TableHead />
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {OPTIONAL_DEPENDENCIES.map((dep) => {
+            const isInstalled = dep.packagesRequired.every((pkg) =>
+              installedPackageNames.has(pkg.name.split("[")[0]),
+            );
+            const packageSpec = dep.packagesRequired
+              .map((pkg) => pkg.name)
+              .join(", ");
+
+            return (
+              <TableRow key={dep.id} className="text-sm">
+                <TableCell>{dep.description}</TableCell>
+                <TableCell className="font-mono text-xs">
+                  {packageSpec}
+                </TableCell>
+                <TableCell>
+                  {isInstalled ? (
+                    <div className="flex items-center">
+                      <CheckCircleIcon className="h-4 w-4 text-[var(--grass-10)] mr-2" />
+                      <span>Installed</span>
+                    </div>
+                  ) : (
+                    <div className="flex items-center">
+                      <XCircleIcon className="h-4 w-4 text-[var(--red-10)] mr-2" />
+                      <InstallButton
+                        packageSpecs={[
+                          ...dep.packagesRequired,
+                          ...dep.additionalPackageInstalls,
+                        ]}
+                        packageManager={packageManager}
+                        onSuccess={reload}
+                      />
+                    </div>
+                  )}
+                </TableCell>
+              </TableRow>
+            );
+          })}
+        </TableBody>
+      </Table>
+    </div>
+  );
+};
+
+const InstallButton: React.FC<{
+  packageSpecs: Package[];
+  packageManager: string;
+  onSuccess: () => void;
+}> = ({ packageSpecs, packageManager, onSuccess }) => {
+  const [loading, setLoading] = React.useState(false);
+
+  const handleInstall = async () => {
+    try {
+      setLoading(true);
+      const packageSpec = packageSpecs
+        .map((pkg) => {
+          if (pkg.minVersion) {
+            return `${pkg.name}>=${pkg.minVersion}`;
+          }
+          return pkg.name;
+        })
+        .join(" ");
+      const response = await addPackage({ package: packageSpec });
+      if (response.success) {
+        onSuccess();
+        toast({
+          title: "Package installed",
+          description: (
+            <span>
+              The packages{" "}
+              <Kbd className="inline">
+                {packageSpecs.map((pkg) => pkg.name).join(", ")}
+              </Kbd>{" "}
+              have been added to your environment.
+            </span>
+          ),
+        });
+      } else {
+        toast({
+          title: "Failed to install package",
+          description: response.error,
+          variant: "danger",
+        });
+      }
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <Button
+      size="xs"
+      variant="outline"
+      className={cn("text-xs", loading && "opacity-50 cursor-not-allowed")}
+      onClick={handleInstall}
+      disabled={loading}
+    >
+      {loading ? (
+        <Spinner size="small" className="mr-2 h-3 w-3" />
+      ) : (
+        <BoxIcon className="mr-2 h-3 w-3" />
+      )}
+      Install with {packageManager}
+    </Button>
+  );
+};
diff --git a/frontend/src/components/app-config/user-config-form.tsx b/frontend/src/components/app-config/user-config-form.tsx
index cfae96844b2..775c719879b 100644
--- a/frontend/src/components/app-config/user-config-form.tsx
+++ b/frontend/src/components/app-config/user-config-form.tsx
@@ -49,9 +49,9 @@ import { Textarea } from "../ui/textarea";
 import { get } from "lodash-es";
 import { Tooltip } from "../ui/tooltip";
 import { getMarimoVersion } from "@/core/dom/marimo-tag";
+import { OptionalFeatures } from "./optional-features";
 
 const formItemClasses = "flex flex-row items-center space-x-1 space-y-0";
-
 const categories = [
   {
     id: "editor",
@@ -83,6 +83,12 @@ const categories = [
     Icon: BrainIcon,
     className: "bg-[linear-gradient(45deg,var(--purple-5),var(--cyan-5))]",
   },
+  {
+    id: "optionalDeps",
+    label: "Optional Dependencies",
+    Icon: FolderCog2,
+    className: "bg-[var(--orange-4)]",
+  },
   {
     id: "labs",
     label: "Labs",
@@ -1061,6 +1067,8 @@ export const UserConfigForm: React.FC = () => {
             </SettingGroup>
           </>
         );
+      case "optionalDeps":
+        return <OptionalFeatures />;
       case "labs":
         return (
           <SettingGroup title="Experimental Features">
@@ -1144,7 +1152,7 @@ export const UserConfigForm: React.FC = () => {
             setActiveCategory(value as SettingCategoryId)
           }
           orientation="vertical"
-          className="w-1/3 pr-4 border-r h-full overflow-auto p-6"
+          className="w-1/3 border-r h-full overflow-auto p-3"
         >
           <TabsList className="self-start max-h-none flex flex-col gap-2 shrink-0 bg-background flex-1 min-h-full">
             {categories.map((category) => (
@@ -1153,16 +1161,16 @@ export const UserConfigForm: React.FC = () => {
                 value={category.id}
                 className="w-full text-left p-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start"
               >
-                <div className="flex gap-4 items-center text-lg">
+                <div className="flex gap-4 items-center text-lg overflow-hidden">
                   <span
                     className={cn(
                       category.className,
-                      "w-8 h-8 rounded flex items-center justify-center text-muted-foreground",
+                      "w-8 h-8 rounded flex items-center justify-center text-muted-foreground flex-shrink-0",
                     )}
                   >
                     <category.Icon className="w-4 h-4" />
                   </span>
-                  {category.label}
+                  <span className="truncate">{category.label}</span>
                 </div>
               </TabsTrigger>
             ))}
@@ -1189,7 +1197,7 @@ const SettingGroup = ({
 }: { title: string; children: React.ReactNode }) => {
   return (
     <div className="flex flex-col gap-4 pb-4">
-      <SettingSubtitle className="text-base">{title}</SettingSubtitle>
+      <SettingSubtitle>{title}</SettingSubtitle>
       {children}
     </div>
   );
diff --git a/frontend/src/components/editor/renderers/CellArray.tsx b/frontend/src/components/editor/renderers/CellArray.tsx
index a9c95e4b7a0..e72b41aef56 100644
--- a/frontend/src/components/editor/renderers/CellArray.tsx
+++ b/frontend/src/components/editor/renderers/CellArray.tsx
@@ -41,9 +41,7 @@ import { AddCellWithAI } from "../ai/add-cell-with-ai";
 import type { Milliseconds } from "@/utils/time";
 import { SQLLanguageAdapter } from "@/core/codemirror/language/sql";
 import { MarkdownLanguageAdapter } from "@/core/codemirror/language/markdown";
-import { capabilitiesAtom } from "@/core/config/capabilities";
 import { Tooltip } from "@/components/ui/tooltip";
-import { Kbd } from "@/components/ui/kbd";
 import { FloatingOutline } from "../chrome/panels/outline/floating-outline";
 import {
   horizontalListSortingStrategy,
@@ -307,7 +305,6 @@ const AddCellButtons: React.FC<{
   const autoInstantiate = useAtomValue(autoInstantiateAtom);
   const [isAiButtonOpen, isAiButtonOpenActions] = useBoolean(false);
   const aiEnabled = useAtomValue(aiEnabledAtom);
-  const sqlCapabilities = useAtomValue(capabilitiesAtom).sql;
 
   const buttonClass = cn(
     "mb-0 rounded-none sm:px-4 md:px-5 lg:px-8 tracking-wide no-wrap whitespace-nowrap",
@@ -352,42 +349,23 @@ const AddCellButtons: React.FC<{
           <SquareMIcon className="mr-2 size-4 flex-shrink-0" />
           Markdown
         </Button>
-        <Tooltip
-          content={
-            sqlCapabilities ? null : (
-              <div className="flex flex-col">
-                <span>
-                  Additional dependencies required:
-                  <Kbd className="inline">pip install 'marimo[sql]'</Kbd>.
-                </span>
-                <span>
-                  You will need to restart the notebook after installing.
-                </span>
-              </div>
-            )
-          }
-          delayDuration={100}
-          asChild={false}
-        >
-          <Button
-            className={buttonClass}
-            variant="text"
-            size="sm"
-            disabled={!sqlCapabilities}
-            onClick={() => {
-              maybeAddMarimoImport(autoInstantiate, createNewCell);
+        <Button
+          className={buttonClass}
+          variant="text"
+          size="sm"
+          onClick={() => {
+            maybeAddMarimoImport(autoInstantiate, createNewCell);
 
-              createNewCell({
-                cellId: { type: "__end__", columnId },
-                before: false,
-                code: new SQLLanguageAdapter().getDefaultCode(),
-              });
-            }}
-          >
-            <DatabaseIcon className="mr-2 size-4 flex-shrink-0" />
-            SQL
-          </Button>
-        </Tooltip>
+            createNewCell({
+              cellId: { type: "__end__", columnId },
+              before: false,
+              code: new SQLLanguageAdapter().getDefaultCode(),
+            });
+          }}
+        >
+          <DatabaseIcon className="mr-2 size-4 flex-shrink-0" />
+          SQL
+        </Button>
         <Tooltip
           content={
             aiEnabled ? null : <span>Enable via settings under AI Assist</span>
diff --git a/frontend/src/core/codemirror/language/__tests__/extension.test.ts b/frontend/src/core/codemirror/language/__tests__/extension.test.ts
index 45ff2348bdb..4dded16d90d 100644
--- a/frontend/src/core/codemirror/language/__tests__/extension.test.ts
+++ b/frontend/src/core/codemirror/language/__tests__/extension.test.ts
@@ -1,5 +1,5 @@
 /* Copyright 2024 Marimo. All rights reserved. */
-import { beforeAll, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
 import {
   adaptiveLanguageConfiguration,
   getInitialLanguageAdapter,
@@ -8,8 +8,6 @@ import {
 } from "../extension";
 import { EditorState } from "@codemirror/state";
 import { OverridingHotkeyProvider } from "@/core/hotkeys/hotkeys";
-import { store } from "@/core/state/jotai";
-import { capabilitiesAtom } from "@/core/config/capabilities";
 import { EditorView } from "@codemirror/view";
 import type { CellId } from "@/core/cells/ids";
 import { cellConfigExtension } from "../../config/extension";
@@ -45,13 +43,6 @@ function createState(content: string, selection?: { anchor: number }) {
 }
 
 describe("getInitialLanguageAdapter", () => {
-  beforeAll(() => {
-    store.set(capabilitiesAtom, {
-      sql: true,
-      terminal: true,
-    });
-  });
-
   it("should return python", () => {
     let state = createState("def f():\n  return 1");
     expect(getInitialLanguageAdapter(state).type).toBe("python");
diff --git a/frontend/src/core/codemirror/language/__tests__/sql.test.ts b/frontend/src/core/codemirror/language/__tests__/sql.test.ts
index d9391d54d9b..f92ab5a9bfb 100644
--- a/frontend/src/core/codemirror/language/__tests__/sql.test.ts
+++ b/frontend/src/core/codemirror/language/__tests__/sql.test.ts
@@ -1,16 +1,7 @@
 /* Copyright 2024 Marimo. All rights reserved. */
-import {
-  expect,
-  describe,
-  it,
-  beforeAll,
-  afterAll,
-  afterEach,
-  beforeEach,
-} from "vitest";
+import { expect, describe, it, afterAll, afterEach, beforeEach } from "vitest";
 import { SQLCompletionStore, SQLLanguageAdapter } from "../sql";
 import { store } from "@/core/state/jotai";
-import { capabilitiesAtom } from "@/core/config/capabilities";
 import {
   dataSourceConnectionsAtom,
   DEFAULT_ENGINE,
@@ -22,13 +13,6 @@ import { PostgreSQL } from "@codemirror/lang-sql";
 const adapter = new SQLLanguageAdapter();
 
 describe("SQLLanguageAdapter", () => {
-  beforeAll(() => {
-    store.set(capabilitiesAtom, {
-      sql: true,
-      terminal: true,
-    });
-  });
-
   describe("transformIn", () => {
     afterAll(() => {
       adapter.engine = DEFAULT_ENGINE;
diff --git a/frontend/src/core/codemirror/language/sql.ts b/frontend/src/core/codemirror/language/sql.ts
index e852c752bf9..33d8f31f873 100644
--- a/frontend/src/core/codemirror/language/sql.ts
+++ b/frontend/src/core/codemirror/language/sql.ts
@@ -11,7 +11,6 @@ import {
 } from "@codemirror/autocomplete";
 import { store } from "@/core/state/jotai";
 import { type QuotePrefixKind, upgradePrefixKind } from "./utils/quotes";
-import { capabilitiesAtom } from "@/core/config/capabilities";
 import { MarkdownLanguageAdapter } from "./markdown";
 import {
   dataConnectionsMapAtom,
@@ -116,11 +115,6 @@ export class SQLLanguageAdapter implements LanguageAdapter {
   }
 
   isSupported(pythonCode: string): boolean {
-    const sqlCapabilities = store.get(capabilitiesAtom).sql;
-    if (!sqlCapabilities) {
-      return false;
-    }
-
     if (pythonCode.trim() === "") {
       return true;
     }
diff --git a/frontend/src/core/config/capabilities.ts b/frontend/src/core/config/capabilities.ts
index a4997aa0378..7f21f176a5f 100644
--- a/frontend/src/core/config/capabilities.ts
+++ b/frontend/src/core/config/capabilities.ts
@@ -3,6 +3,5 @@ import { atom } from "jotai";
 import type { Capabilities } from "../kernel/messages";
 
 export const capabilitiesAtom = atom<Capabilities>({
-  sql: false,
   terminal: false,
 });
diff --git a/frontend/src/core/kernel/handlers.ts b/frontend/src/core/kernel/handlers.ts
index b8ed7b02866..57fda1ebac2 100644
--- a/frontend/src/core/kernel/handlers.ts
+++ b/frontend/src/core/kernel/handlers.ts
@@ -18,7 +18,6 @@ import { AppConfigSchema, type AppConfig } from "../config/config-schema";
 import { type CellData, createCell } from "../cells/types";
 import { VirtualFileTracker } from "../static/virtual-file-tracker";
 import type { CellId, UIElementId } from "../cells/ids";
-import { isWasm } from "../wasm/utils";
 
 export function handleKernelReady(
   data: OperationMessageData<"kernel-ready">,
@@ -95,11 +94,7 @@ export function handleKernelReady(
   if (parsedAppConfig.success) {
     setAppConfig(parsedAppConfig.data);
   }
-  setCapabilities({
-    ...capabilities,
-    // always enable sql if wasm
-    sql: capabilities.sql || isWasm(),
-  });
+  setCapabilities(capabilities);
 
   // If resumed, we don't need to instantiate the UI elements,
   // and we should read in th existing values from the kernel.
diff --git a/marimo/_dependencies/dependencies.py b/marimo/_dependencies/dependencies.py
index caf2c910f26..5a6ec6c9e5c 100644
--- a/marimo/_dependencies/dependencies.py
+++ b/marimo/_dependencies/dependencies.py
@@ -7,6 +7,8 @@
 import sys
 from dataclasses import dataclass
 
+from marimo._dependencies.errors import ManyModulesNotFoundError
+
 
 @dataclass
 class Dependency:
@@ -214,3 +216,12 @@ def which(pkg: str) -> bool:
         Checks if a CLI command is installed.
         """
         return shutil.which(pkg) is not None
+
+    @staticmethod
+    def require_many(why: str, *dependencies: Dependency) -> None:
+        missing = [dep.pkg for dep in dependencies if not dep.has()]
+        if missing:
+            raise ManyModulesNotFoundError(
+                missing,
+                f"The following packages are required {why}: {', '.join(missing)}",
+            )
diff --git a/marimo/_dependencies/errors.py b/marimo/_dependencies/errors.py
new file mode 100644
index 00000000000..de405fdfb93
--- /dev/null
+++ b/marimo/_dependencies/errors.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+
+class ManyModulesNotFoundError(ModuleNotFoundError):
+    """
+    Raised when multiple modules are not found.
+    """
+
+    package_names: list[str]
+
+    def __init__(self, package_names: list[str], msg: str) -> None:
+        self.package_names = package_names
+        super().__init__(msg)
diff --git a/marimo/_messaging/ops.py b/marimo/_messaging/ops.py
index 1d9f4fabb61..624e9911e63 100644
--- a/marimo/_messaging/ops.py
+++ b/marimo/_messaging/ops.py
@@ -31,7 +31,6 @@
     DataTable,
     DataTableSource,
 )
-from marimo._dependencies.dependencies import DependencyManager
 from marimo._messaging.cell_output import CellChannel, CellOutput
 from marimo._messaging.completion_option import CompletionOption
 from marimo._messaging.context import RUN_ID_CTX, RunId_t
@@ -410,11 +409,9 @@ class CompletedRun(Op):
 
 @dataclass
 class KernelCapabilities:
-    sql: bool = False
     terminal: bool = False
 
     def __post_init__(self) -> None:
-        self.sql = DependencyManager.duckdb.has_at_version(min_version="1.0.0")
         # Only available in mac/linux
         self.terminal = not is_windows() and not is_pyodide()
 
diff --git a/marimo/_runtime/runner/cell_runner.py b/marimo/_runtime/runner/cell_runner.py
index a6e4a50d2d5..8587126336d 100644
--- a/marimo/_runtime/runner/cell_runner.py
+++ b/marimo/_runtime/runner/cell_runner.py
@@ -14,6 +14,7 @@
 from marimo._ast.cell import CellImpl
 from marimo._ast.variables import unmangle_local
 from marimo._config.config import ExecutionType, OnCellChangeType
+from marimo._dependencies.errors import ManyModulesNotFoundError
 from marimo._loggers import marimo_logger
 from marimo._messaging.errors import (
     Error,
@@ -424,7 +425,10 @@ async def run(self, cell_id: CellId_t) -> RunResult:
                     exception = unwrapped_exception
 
             # Handle other special runtime errors.
-            elif isinstance(unwrapped_exception, ModuleNotFoundError):
+            elif isinstance(
+                unwrapped_exception,
+                (ModuleNotFoundError, ManyModulesNotFoundError),
+            ):
                 self.missing_packages = True
 
             elif isinstance(unwrapped_exception, MarimoStopError):
diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py
index 9311bd3170b..5afa6e438db 100644
--- a/marimo/_runtime/runtime.py
+++ b/marimo/_runtime/runtime.py
@@ -32,6 +32,7 @@
     get_column_preview_for_duckdb,
 )
 from marimo._dependencies.dependencies import DependencyManager
+from marimo._dependencies.errors import ManyModulesNotFoundError
 from marimo._messaging.cell_output import CellChannel
 from marimo._messaging.context import http_request_context, run_id_context
 from marimo._messaging.errors import (
@@ -1310,15 +1311,22 @@ def _broadcast_missing_packages(self, runner: cell_runner.Runner) -> None:
         module_not_found_errors = [
             e
             for e in runner.exceptions.values()
-            if isinstance(e, ModuleNotFoundError)
+            if isinstance(e, (ModuleNotFoundError, ManyModulesNotFoundError))
         ]
         if (
             len(module_not_found_errors) > 0
             and self.package_manager is not None
         ):
+            missing_modules: set[str] = set()
+            for e in module_not_found_errors:
+                if isinstance(e, ManyModulesNotFoundError):
+                    missing_modules.update(e.package_names)
+                elif e.name is not None:
+                    missing_modules.add(e.name)
+
             # Grab missing modules from module registry and from module not found errors
-            missing_modules = self.module_registry.missing_modules() | set(
-                e.name for e in module_not_found_errors if e.name is not None
+            missing_modules = (
+                self.module_registry.missing_modules() | missing_modules
             )
 
             missing_packages = [
diff --git a/marimo/_server/api/endpoints/ai.py b/marimo/_server/api/endpoints/ai.py
index 06cf15daada..ecb12984c16 100644
--- a/marimo/_server/api/endpoints/ai.py
+++ b/marimo/_server/api/endpoints/ai.py
@@ -15,6 +15,7 @@
 )
 from marimo._ai._types import ChatMessage
 from marimo._config.config import MarimoConfig
+from marimo._dependencies.dependencies import DependencyManager
 from marimo._server.ai.prompts import Prompter
 from marimo._server.api.deps import AppState
 from marimo._server.api.status import HTTPStatus
@@ -59,18 +60,14 @@
 
 
 def get_openai_client(config: MarimoConfig) -> OpenAI:
-    try:
-        from urllib.parse import parse_qs, urlparse
+    DependencyManager.openai.require(why="for AI assistance with OpenAI")
 
-        from openai import (  # type: ignore[import-not-found]
-            AzureOpenAI,
-            OpenAI,
-        )
-    except ImportError:
-        raise HTTPException(
-            status_code=HTTPStatus.BAD_REQUEST,
-            detail="OpenAI not installed. Add 'openai' using the package installer in the sidebar.",
-        ) from None
+    from urllib.parse import parse_qs, urlparse
+
+    from openai import (  # type: ignore[import-not-found]
+        AzureOpenAI,
+        OpenAI,
+    )
 
     if "ai" not in config:
         raise HTTPException(
@@ -124,13 +121,9 @@ def get_openai_client(config: MarimoConfig) -> OpenAI:
 
 
 def get_anthropic_client(config: MarimoConfig) -> Client:
-    try:
-        from anthropic import Client  # type: ignore[import-not-found]
-    except ImportError:
-        raise HTTPException(
-            status_code=HTTPStatus.BAD_REQUEST,
-            detail="Anthropic not installed. Add 'anthropic' using the package installer in the sidebar.",
-        ) from None
+    DependencyManager.anthropic.require(why="for AI assistance with Anthropic")
+
+    from anthropic import Client  # type: ignore[import-not-found]
 
     if "ai" not in config:
         raise HTTPException(
@@ -289,13 +282,9 @@ def get_google_client(config: MarimoConfig, model: str) -> GenerativeModel:
     try:
         import google.generativeai as genai  # type: ignore[import-not-found]
     except ImportError:
-        raise HTTPException(
-            status_code=HTTPStatus.BAD_REQUEST,
-            detail=(
-                "Google AI not installed. "
-                "Add 'google-generativeai' using the package installer in the sidebar."
-            ),
-        ) from None
+        DependencyManager.google_ai.require(
+            why="for AI assistance with Google AI"
+        )
 
     if "ai" not in config or "google" not in config["ai"]:
         raise HTTPException(
diff --git a/marimo/_sql/engines.py b/marimo/_sql/engines.py
index ab86f69fd12..b1a3f61da63 100644
--- a/marimo/_sql/engines.py
+++ b/marimo/_sql/engines.py
@@ -68,7 +68,7 @@ def execute(self, query: str) -> Any:
         elif DependencyManager.pandas.has():
             return relation.df()
         else:
-            raise_df_import_error("polars")
+            raise_df_import_error("polars[pyarrow]")
 
     @staticmethod
     def is_compatible(var: Any) -> bool:
@@ -145,7 +145,7 @@ def execute(self, query: str) -> Any:
         if not (
             DependencyManager.polars.has() or DependencyManager.pandas.has()
         ):
-            raise_df_import_error("polars")
+            raise_df_import_error("polars[pyarrow]")
 
         from sqlalchemy import text
 
diff --git a/marimo/_sql/sql.py b/marimo/_sql/sql.py
index c38ba67ee38..2fa437f0810 100644
--- a/marimo/_sql/sql.py
+++ b/marimo/_sql/sql.py
@@ -54,7 +54,11 @@ def sql(
         return None
 
     if engine is None:
-        DependencyManager.duckdb.require("to execute sql")
+        DependencyManager.require_many(
+            "to execute sql",
+            DependencyManager.duckdb,
+            DependencyManager.sqlglot,
+        )
         sql_engine = DuckDBEngine(connection=None)
     elif SQLAlchemyEngine.is_compatible(engine):
         sql_engine = SQLAlchemyEngine(engine)  # type: ignore
@@ -94,7 +98,7 @@ def sql(
             )
             df = df.head(default_result_limit)
         else:
-            raise_df_import_error("polars")
+            raise_df_import_error("polars[pyarrow]")
 
     if output:
         from marimo._plugins.ui._impl import table
diff --git a/openapi/api.yaml b/openapi/api.yaml
index 457247c3a8b..a25855be3f7 100644
--- a/openapi/api.yaml
+++ b/openapi/api.yaml
@@ -1076,12 +1076,9 @@ components:
           type: object
         capabilities:
           properties:
-            sql:
-              type: boolean
             terminal:
               type: boolean
           required:
-          - sql
           - terminal
           type: object
         cell_ids:
diff --git a/openapi/src/api.ts b/openapi/src/api.ts
index 66f253de547..a4e5012cc4f 100644
--- a/openapi/src/api.ts
+++ b/openapi/src/api.ts
@@ -2584,7 +2584,6 @@ export interface components {
         width: "normal" | "compact" | "medium" | "full";
       };
       capabilities: {
-        sql: boolean;
         terminal: boolean;
       };
       cell_ids: string[];
diff --git a/tests/_dependencies/test_dependencies.py b/tests/_dependencies/test_dependencies.py
index 091fa4d2311..c9340a3268c 100644
--- a/tests/_dependencies/test_dependencies.py
+++ b/tests/_dependencies/test_dependencies.py
@@ -7,6 +7,7 @@
     DependencyManager,
     _version_check,
 )
+from marimo._dependencies.errors import ManyModulesNotFoundError
 
 
 def test_dependencies() -> None:
@@ -167,3 +168,39 @@ def test_version_check():
         str(excinfo.value)
         == "Mismatched version of test: expected <3.0.0, got 3.0.0"
     )
+
+
+def test_require_many() -> None:
+    """Test that require_many correctly raises ManyModulesNotFoundError."""
+
+    missing1 = Dependency("missing1")
+    missing2 = Dependency("missing2")
+
+    # Test with all missing dependencies
+    with pytest.raises(ManyModulesNotFoundError) as excinfo:
+        DependencyManager.require_many(
+            "for testing multiple dependencies",
+            missing1,
+            missing2,
+        )
+
+    assert excinfo.value.package_names == ["missing1", "missing2"]
+    assert "for testing multiple dependencies" in str(excinfo.value)
+
+    # Test with some dependencies available (if pandas is installed)
+    if DependencyManager.pandas.has():
+        with pytest.raises(ManyModulesNotFoundError) as excinfo:
+            DependencyManager.require_many(
+                "for testing mixed dependencies",
+                DependencyManager.pandas,
+                missing1,
+            )
+
+        assert excinfo.value.package_names == ["missing1"]
+        assert "for testing mixed dependencies" in str(excinfo.value)
+
+        # Test with all dependencies available
+        result = DependencyManager.require_many(
+            "for testing available dependencies", DependencyManager.pandas
+        )
+        assert result is None
diff --git a/tests/_runtime/test_manage_script_metadata.py b/tests/_runtime/test_manage_script_metadata.py
index 7471b4914ba..b63bf1da549 100644
--- a/tests/_runtime/test_manage_script_metadata.py
+++ b/tests/_runtime/test_manage_script_metadata.py
@@ -12,6 +12,7 @@
 from marimo._config.config import merge_default_config
 from marimo._config.settings import GLOBAL_SETTINGS
 from marimo._dependencies.dependencies import DependencyManager
+from marimo._dependencies.errors import ManyModulesNotFoundError
 from marimo._messaging.ops import InstallingPackageAlert, MissingPackageAlert
 from marimo._runtime.packages.package_managers import create_package_manager
 from marimo._runtime.packages.pypi_package_manager import (
@@ -338,6 +339,10 @@ def __init__(self) -> None:
                 "ibis": ModuleNotFoundError(
                     "No module named 'ibis'", name="ibis"
                 ),
+                "cell3": ManyModulesNotFoundError(
+                    package_names=["grouped_one", "grouped_two"],
+                    msg="Missing one and two",
+                ),
             }
 
     # Case 1: Auto-install enabled
@@ -353,7 +358,12 @@ def __init__(self) -> None:
         request = control_requests[0]
         assert isinstance(request, InstallMissingPackagesRequest)
         assert request.manager == package_manager.name
-        assert request.versions == {"numpy": "", "ibis-framework[duckdb]": ""}
+        assert request.versions == {
+            "numpy": "",
+            "ibis-framework[duckdb]": "",
+            "grouped-one": "",
+            "grouped-two": "",
+        }
         assert len(broadcast_messages) == 0
 
         # Case 2: Auto-install disabled
@@ -367,7 +377,12 @@ def __init__(self) -> None:
         assert len(broadcast_messages) == 1
         alert = broadcast_messages[0]
         assert isinstance(alert, MissingPackageAlert)
-        assert alert.packages == ["ibis-framework[duckdb]", "numpy"]
+        assert alert.packages == [
+            "grouped-one",
+            "grouped-two",
+            "ibis-framework[duckdb]",
+            "numpy",
+        ]
         assert alert.isolated == is_python_isolated()
 
         # Case 3: Multiple missing modules
@@ -387,6 +402,8 @@ def __init__(self) -> None:
         assert isinstance(request, InstallMissingPackagesRequest)
         assert request.manager == package_manager.name
         assert request.versions == {
+            "grouped-one": "",
+            "grouped-two": "",
             "ibis-framework[duckdb]": "",
             "numpy": "",
             "pandas": "",
@@ -407,6 +424,8 @@ def __init__(self) -> None:
         assert isinstance(request, InstallMissingPackagesRequest)
         assert request.manager == package_manager.name
         assert request.versions == {
+            "grouped-one": "",
+            "grouped-two": "",
             "ibis-framework[duckdb]": "",
             "pandas": "",
             "scipy": "",


Code review comments:
diff_hunk: @@ -1310,15 +1311,22 @@ def _broadcast_missing_packages(self, runner: cell_runner.Runner) -> None:
         module_not_found_errors = [
             e
             for e in runner.exceptions.values()
-            if isinstance(e, ModuleNotFoundError)
+            if isinstance(e, (ModuleNotFoundError, ManyModulesNotFoundError))
         ]
         if (
             len(module_not_found_errors) > 0
             and self.package_manager is not None
         ):
+            missing_modules: set[str] = set()
+            for e in module_not_found_errors:
+                if isinstance(e, ManyModulesNotFoundError):
+                    missing_modules.update(e.package_names)
path: marimo/_runtime/runtime.py
text: @user1:
Should this be adding module names, not package names?

@author:
The module names can be lossy, so I need to add package names. We convert it right below though 

@user1:
just double checking — any issues with the code below? Will adding package names to `missing_modules` make `module_to_package` unhappy?

<img width="610" alt="image" src="https://github.com/user-attachments/assets/7399f4ee-6da9-482b-8759-b76f0f028a6e" />

Like should this be

```python
missing_packages: list[str] = []
missing_modules: set[str] = set()
for e in module_not_found_errors:
    if isinstance(e, ManyModulesNotFoundError):
        missing_packages.extend([p for p in e.package_names if not self.package_manager.attempted_to_install(p)
```


@author:
ah i see now, cleaning it up