Repository: marimo-team/marimo
Pull request: feat: override configuration with pyproject.toml
Fixes #3069 
Related to #2922, but maybe doesn't close it quite yet. 

This adds `pyproject.toml` configuration overrides, so a collection of notebook can pick up the same marimo configuration. This overrides the user's configuration. You can only edit this in the pyproject.toml - and not in the UI. When there is an override, we show in the UI the fields are overridden. 

----

### Overriding settings with pyproject.toml

You can override user configuration settings with a `pyproject.toml` file. This
is useful for sharing configurations across teams or ensuring consistency across
notebooks. You must edit the `pyproject.toml` file directly to override settings.

For example, the following `pyproject.toml` file overrides the `autosave` setting
in the user configuration:

```toml
[tool.marimo.save]
autosave = "after_delay"
```

You can override any user configuration setting in this way. To find these settings run `marimo config show`.

![Screenshot 2024-12-06 at 1 41 54 PM](https://github.com/user-attachments/assets/e711f196-d553-47e6-860b-66cea98fe3d7)
![Screenshot 2024-12-06 at 1 41 40 PM](https://github.com/user-attachments/assets/a6cce068-340f-403f-a8c8-13fcbef4fefd)

![Screenshot 2024-12-06 at 1 42 31 PM](https://github.com/user-attachments/assets/bdb4cdc6-e07c-40e1-9d09-fcc08d8b7199)




Problem statement:
Support configuration overrides in a pyproject.toml
### Description

marimo should pick up configuration overrides from the nearest ancestor `pyproject.toml`. The configuration options should be the same and configured like:


```toml
[tool.marimo.ai]
rules = "- prefer polars over pandas\n- make charts using altair"

[tool.marimo.save]
autosave_delay = 1000
autosave = "after_delay"
format_on_save = true

[tool.marimo.experimental]
multi_column = true
chat_sidebar = true

[tool.marimo.display]
dataframes = "rich"
```

### Suggested solution

This can (at first) only be configured directly from the `project.toml`. The configuration UI in the marimo editor is reserved for user config, but we can show when the user config is overridden by the project config.

### Alternative

_No response_

### Additional context

_No response_

Hints from issue discussion:

Commit message: recursive

Patch to review:
diff --git a/docs/guides/configuration/index.md b/docs/guides/configuration/index.md
index 95c30aa8abf..d9cf65f3cf0 100644
--- a/docs/guides/configuration/index.md
+++ b/docs/guides/configuration/index.md
@@ -26,7 +26,7 @@ App Configuration is specific to each notebook and is stored in the `notebook.py
 - [Custom HTML Head](/guides/configuration/html_head.md)
 - Automatically download HTML snapshots
 
-Configure these settings through the notebook menu.
+Configure these settings through the notebook menu (⚙️) in the top-right corner.
 
 <br clear="left"/>
 
@@ -40,7 +40,7 @@ marimo UI for a more user-friendly experience.
 
 <video controls width="100%" height="100%" align="center" src="/_static/docs-user-config.mp4"> </video>
 
-You can customize the following:
+You can customize the following settings:
 
 - [Runtime](/guides/configuration/runtime_configuration.md), including whether notebooks autorun
 - [Hotkeys](/guides/editor_features/hotkeys.md)
@@ -52,6 +52,7 @@ You can customize the following:
 - [VIM keybindings](/guides/editor_features/overview.md#vim-keybindings)
 - Formatting settings
 - [AI assistance](/guides/editor_features/ai_completion.md)
+- Experimental features
 
 ### User configuration file
 
@@ -65,15 +66,37 @@ marimo searches for the `.marimo.toml` file in the following order:
 If no `.marimo.toml` file is found, marimo creates one for you in an XDG config
 compliant way.
 
-View your current configuration and locate the config file with:
+To view your current configuration and locate the config file, run:
 
 ```bash
 marimo config show
 ```
 
+To describe the user configuration options, run:
+
+```bash
+marimo config describe
+```
+
+### Overriding settings with pyproject.toml
+
+You can override user configuration settings with a `pyproject.toml` file. This
+is useful for sharing configurations across teams or ensuring consistency across
+notebooks. You must edit the `pyproject.toml` file directly to override settings.
+
+For example, the following `pyproject.toml` file overrides the `autosave` setting
+in the user configuration:
+
+```toml
+[tool.marimo.save]
+autosave = "after_delay"
+```
+
+You can override any user configuration setting in this way. To find these settings run `marimo config show`.
+
 ## Environment Variables
 
-There are some configuration options that can be set via environment variables. These are:
+marimo supports the following environment variables for advanced configuration:
 
 | Environment Variable          | Description                                                                                                                  | Default Value   |
 | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- |
@@ -81,3 +104,8 @@ There are some configuration options that can be set via environment variables.
 | `MARIMO_STD_STREAM_MAX_BYTES` | Maximum size of standard stream (stdout/stderr) output that marimo will display. Outputs larger than this will be truncated. | 1,000,000 (1MB) |
 | `MARIMO_SKIP_UPDATE_CHECK`    | If set to "1", marimo will skip checking for updates when starting.                                                          | Not set         |
 | `MARIMO_SQL_DEFAULT_LIMIT`    | Default limit for SQL query results. If not set, no limit is applied.                                                        | Not set         |
+
+### Tips
+
+- The `.marimo.toml` file can be version controlled to share configurations across teams
+- App configurations can be committed with notebooks to ensure consistent appearance
diff --git a/frontend/index.html b/frontend/index.html
index 03261295ec8..e77f1710e50 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -55,7 +55,7 @@
     <marimo-filename hidden>{{ filename }}</marimo-filename>
     <marimo-mode data-mode='{{ mode }}' hidden></marimo-mode>
     <marimo-version data-version='{{ version }}' hidden></marimo-version>
-    <marimo-user-config data-config='{{ user_config }}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{{ user_config }}' data-overrides='{{ config_overrides }}' hidden></marimo-user-config>
     <marimo-app-config data-config='{{ app_config }}' hidden></marimo-app-config>
     <marimo-server-token data-token='{{ server_token }}' hidden></marimo-server-token>
     <title>{{ title }}</title>
diff --git a/frontend/src/components/app-config/user-config-form.tsx b/frontend/src/components/app-config/user-config-form.tsx
index 8fbf776364e..3d2e7c69e9f 100644
--- a/frontend/src/components/app-config/user-config-form.tsx
+++ b/frontend/src/components/app-config/user-config-form.tsx
@@ -2,9 +2,9 @@
 import { SettingSubtitle } from "./common";
 
 import React, { useRef } from "react";
-import { useForm } from "react-hook-form";
+import { type FieldPath, useForm } from "react-hook-form";
 import { zodResolver } from "@hookform/resolvers/zod";
-import { atom, useAtom, useSetAtom } from "jotai";
+import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
 import {
   Form,
   FormControl,
@@ -22,7 +22,7 @@ import { NumberField } from "@/components/ui/number-field";
 import { Kbd } from "@/components/ui/kbd";
 import { CopilotConfig } from "@/core/codemirror/copilot/copilot-config";
 import { KEYMAP_PRESETS } from "@/core/codemirror/keymaps/keymaps";
-import { useUserConfig } from "@/core/config/config";
+import { configOverridesAtom, useUserConfig } from "@/core/config/config";
 import {
   UserConfigSchema,
   PackageManagerNames,
@@ -41,10 +41,13 @@ import {
   CpuIcon,
   BrainIcon,
   FlaskConicalIcon,
+  FolderCog2,
 } from "lucide-react";
 import { cn } from "@/utils/cn";
 import { KNOWN_AI_MODELS } from "./constants";
 import { Textarea } from "../ui/textarea";
+import { get } from "lodash-es";
+import { Tooltip } from "../ui/tooltip";
 
 const formItemClasses = "flex flex-row items-center space-x-1 space-y-0";
 
@@ -149,6 +152,10 @@ export const UserConfigForm: React.FC = () => {
                   />
                 </FormControl>
                 <FormMessage />
+                <IsOverridden
+                  userConfig={config}
+                  name="completion.codeium_api_key"
+                />
               </FormItem>
             )}
           />
@@ -184,6 +191,8 @@ export const UserConfigForm: React.FC = () => {
                         }}
                       />
                     </FormControl>
+                    <FormMessage />
+                    <IsOverridden userConfig={config} name="save.autosave" />
                   </FormItem>
                 )}
               />
@@ -212,6 +221,10 @@ export const UserConfigForm: React.FC = () => {
                       />
                     </FormControl>
                     <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="save.autosave_delay"
+                    />
                   </FormItem>
                 )}
               />
@@ -235,6 +248,11 @@ export const UserConfigForm: React.FC = () => {
                         }}
                       />
                     </FormControl>
+                    <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="save.format_on_save"
+                    />
                   </FormItem>
                 )}
               />
@@ -263,6 +281,10 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="formatting.line_length"
+                      />
                     </FormItem>
 
                     <FormDescription>
@@ -292,6 +314,11 @@ export const UserConfigForm: React.FC = () => {
                           }}
                         />
                       </FormControl>
+                      <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="completion.activate_on_typing"
+                      />
                     </FormItem>
                     <FormDescription>
                       When unchecked, code completion is still available through
@@ -340,6 +367,7 @@ export const UserConfigForm: React.FC = () => {
                         </NativeSelect>
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden userConfig={config} name="keymap.preset" />
                     </FormItem>
 
                     <div>
@@ -389,6 +417,10 @@ export const UserConfigForm: React.FC = () => {
                         </NativeSelect>
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="display.default_width"
+                      />
                     </FormItem>
 
                     <FormDescription>
@@ -421,6 +453,7 @@ export const UserConfigForm: React.FC = () => {
                         </NativeSelect>
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden userConfig={config} name="display.theme" />
                     </FormItem>
 
                     <FormDescription>
@@ -453,6 +486,10 @@ export const UserConfigForm: React.FC = () => {
                       </span>
                     </FormControl>
                     <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="display.code_editor_font_size"
+                    />
                   </FormItem>
                 )}
               />
@@ -481,6 +518,10 @@ export const UserConfigForm: React.FC = () => {
                         </NativeSelect>
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="display.cell_output"
+                      />
                     </FormItem>
 
                     <FormDescription>
@@ -512,6 +553,10 @@ export const UserConfigForm: React.FC = () => {
                         </NativeSelect>
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="display.dataframes"
+                      />
                     </FormItem>
 
                     <FormDescription>
@@ -552,6 +597,10 @@ export const UserConfigForm: React.FC = () => {
                       </NativeSelect>
                     </FormControl>
                     <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="package_management.manager"
+                    />
                   </FormItem>
 
                   <FormDescription>
@@ -605,6 +654,11 @@ export const UserConfigForm: React.FC = () => {
                         onCheckedChange={field.onChange}
                       />
                     </FormControl>
+                    <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="runtime.auto_instantiate"
+                    />
                   </FormItem>
 
                   <FormDescription>
@@ -636,6 +690,11 @@ export const UserConfigForm: React.FC = () => {
                         ))}
                       </NativeSelect>
                     </FormControl>
+                    <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="runtime.on_cell_change"
+                    />
                   </FormItem>
                   <FormDescription>
                     Whether marimo should automatically run cells or just mark
@@ -671,6 +730,11 @@ export const UserConfigForm: React.FC = () => {
                         ))}
                       </NativeSelect>
                     </FormControl>
+                    <FormMessage />
+                    <IsOverridden
+                      userConfig={config}
+                      name="runtime.auto_reload"
+                    />
                   </FormItem>
                   <FormDescription>
                     Whether marimo should automatically reload modules before
@@ -739,6 +803,10 @@ export const UserConfigForm: React.FC = () => {
                         </NativeSelect>
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="completion.copilot"
+                      />
                     </FormItem>
                   </div>
                 )}
@@ -770,6 +838,10 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="ai.open_ai.api_key"
+                      />
                     </FormItem>
                     <FormDescription>
                       Your OpenAI API key from{" "}
@@ -810,6 +882,10 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="ai.anthropic.api_key"
+                      />
                     </FormItem>
                     <FormDescription>
                       Your Anthropic API key from{" "}
@@ -850,6 +926,10 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="ai.google.api_key"
+                      />
                     </FormItem>
                     <FormDescription>
                       Your Google AI API key from{" "}
@@ -899,6 +979,10 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="ai.open_ai.base_url"
+                      />
                     </FormItem>
                     <FormDescription>
                       This URL can be any OpenAI-compatible API endpoint.
@@ -924,6 +1008,10 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden
+                        userConfig={config}
+                        name="ai.open_ai.model"
+                      />
                     </FormItem>
                     <datalist id="ai-model-datalist">
                       {KNOWN_AI_MODELS.map((model) => (
@@ -959,6 +1047,7 @@ export const UserConfigForm: React.FC = () => {
                         />
                       </FormControl>
                       <FormMessage />
+                      <IsOverridden userConfig={config} name="ai.rules" />
                     </FormItem>
                     <FormDescription>
                       Custom rules to include in all AI completion prompts.
@@ -1093,3 +1182,50 @@ const SettingGroup = ({
     </div>
   );
 };
+
+const IsOverridden = ({
+  userConfig,
+  name,
+}: { userConfig: UserConfig; name: FieldPath<UserConfig> }) => {
+  const currentValue = get(userConfig, name);
+  const overrides = useAtomValue(configOverridesAtom);
+  const overriddenValue = get(overrides as UserConfig, name);
+  if (overriddenValue == null) {
+    return null;
+  }
+
+  if (currentValue === overriddenValue) {
+    return null;
+  }
+
+  return (
+    <Tooltip
+      content={
+        <>
+          <span>
+            This setting is overridden by{" "}
+            <Kbd className="inline">pyproject.toml</Kbd>.
+          </span>
+          <br />
+          <span>
+            Edit the <Kbd className="inline">pyproject.toml</Kbd> file directly
+            to change this setting.
+          </span>
+          <br />
+          <span>
+            User value: <strong>{String(currentValue)}</strong>
+          </span>
+          <br />
+          <span>
+            Project value: <strong>{String(overriddenValue)}</strong>
+          </span>
+        </>
+      }
+    >
+      <span className="text-[var(--amber-12)] text-xs flex items-center gap-1 border rounded px-2 py-1 bg-[var(--amber-2)] border-[var(--amber-6)] ml-1">
+        <FolderCog2 className="w-3 h-3" />
+        Overridden by pyproject.toml [{String(overriddenValue)}]
+      </span>
+    </Tooltip>
+  );
+};
diff --git a/frontend/src/components/editor/actions/useConfigActions.tsx b/frontend/src/components/editor/actions/useConfigActions.tsx
index 6f59a8aa321..465f23a3146 100644
--- a/frontend/src/components/editor/actions/useConfigActions.tsx
+++ b/frontend/src/components/editor/actions/useConfigActions.tsx
@@ -3,12 +3,12 @@ import { saveAppConfig, saveUserConfig } from "@/core/network/requests";
 import type { ActionButton } from "./types";
 import type { AppConfig, UserConfig } from "@/core/config/config-schema";
 import { getAppWidths } from "@/core/config/widths";
-import { useAppConfig, useUserConfig } from "@/core/config/config";
+import { useAppConfig, useResolvedMarimoConfig } from "@/core/config/config";
 import { useTheme } from "@/theme/useTheme";
 
 export function useConfigActions() {
   const { theme } = useTheme();
-  const [config, setConfig] = useUserConfig();
+  const [config, setConfig] = useResolvedMarimoConfig();
   const [appConfig, setAppConfig] = useAppConfig();
 
   const handleUserConfig = async (values: UserConfig) => {
diff --git a/frontend/src/components/editor/chrome/panels/packages-panel.tsx b/frontend/src/components/editor/chrome/panels/packages-panel.tsx
index f269cc523dc..97553d606a3 100644
--- a/frontend/src/components/editor/chrome/panels/packages-panel.tsx
+++ b/frontend/src/components/editor/chrome/panels/packages-panel.tsx
@@ -4,7 +4,7 @@ import { BoxIcon, HelpCircleIcon } from "lucide-react";
 import { PanelEmptyState } from "./empty-state";
 
 import { useAsyncData } from "@/hooks/useAsyncData";
-import { useUserConfig } from "@/core/config/config";
+import { useResolvedMarimoConfig } from "@/core/config/config";
 import {
   addPackage,
   getPackageList,
@@ -29,7 +29,7 @@ import { Events } from "@/utils/events";
 import { copyToClipboard } from "@/utils/copy";
 
 export const PackagesPanel: React.FC = () => {
-  const [config] = useUserConfig();
+  const [config] = useResolvedMarimoConfig();
   const packageManager = config.package_management.manager;
   const { data, loading, error, reload } = useAsyncData(
     () => getPackageList(),
diff --git a/frontend/src/components/editor/chrome/wrapper/copilot-status.tsx b/frontend/src/components/editor/chrome/wrapper/copilot-status.tsx
index b3f7ff58396..e7ed23deace 100644
--- a/frontend/src/components/editor/chrome/wrapper/copilot-status.tsx
+++ b/frontend/src/components/editor/chrome/wrapper/copilot-status.tsx
@@ -7,7 +7,7 @@ import {
   githubCopilotLoadingVersion,
 } from "@/core/codemirror/copilot/state";
 import { atom, useAtomValue, useSetAtom } from "jotai";
-import { aiEnabledAtom, userConfigAtom } from "@/core/config/config";
+import { aiEnabledAtom, resolvedMarimoConfigAtom } from "@/core/config/config";
 import { GitHubCopilotIcon } from "@/components/icons/github-copilot";
 import { SparklesIcon } from "lucide-react";
 import { FooterItem } from "./footer-item";
@@ -48,10 +48,10 @@ export const AIStatusIcon: React.FC = () => {
 };
 
 const copilotAtom = atom((get) => {
-  return get(userConfigAtom).completion.copilot;
+  return get(resolvedMarimoConfigAtom).completion.copilot;
 });
 const aiAtom = atom((get) => {
-  return get(userConfigAtom).ai;
+  return get(resolvedMarimoConfigAtom).ai;
 });
 
 export function useOpenAISettings() {
diff --git a/frontend/src/components/editor/chrome/wrapper/footer.tsx b/frontend/src/components/editor/chrome/wrapper/footer.tsx
index 4838a40a012..eb9889e95cb 100644
--- a/frontend/src/components/editor/chrome/wrapper/footer.tsx
+++ b/frontend/src/components/editor/chrome/wrapper/footer.tsx
@@ -7,7 +7,7 @@ import { useAtomValue } from "jotai";
 import { cellErrorCount } from "@/core/cells/cells";
 import { type PanelDescriptor, PANELS } from "../types";
 import { MachineStats } from "./machine-stats";
-import { useUserConfig } from "@/core/config/config";
+import { useResolvedMarimoConfig } from "@/core/config/config";
 import {
   PowerOffIcon,
   TerminalSquareIcon,
@@ -33,7 +33,7 @@ import { isWasm } from "@/core/wasm/utils";
 export const Footer: React.FC = () => {
   const { selectedPanel, isTerminalOpen } = useChromeState();
   const { openApplication, toggleTerminal } = useChromeActions();
-  const [config, setConfig] = useUserConfig();
+  const [config, setConfig] = useResolvedMarimoConfig();
   const errorCount = useAtomValue(cellErrorCount);
 
   const renderIcon = ({ Icon }: PanelDescriptor, className?: string) => {
diff --git a/frontend/src/components/editor/controls/keyboard-shortcuts.tsx b/frontend/src/components/editor/controls/keyboard-shortcuts.tsx
index f3e97b2225c..9c6e04be8ce 100644
--- a/frontend/src/components/editor/controls/keyboard-shortcuts.tsx
+++ b/frontend/src/components/editor/controls/keyboard-shortcuts.tsx
@@ -18,7 +18,7 @@ import { atom, useAtom, useAtomValue } from "jotai";
 import { useState } from "react";
 import { EditIcon, XIcon } from "lucide-react";
 import { Input } from "@/components/ui/input";
-import { hotkeysAtom, useUserConfig } from "@/core/config/config";
+import { hotkeysAtom, useResolvedMarimoConfig } from "@/core/config/config";
 import { saveUserConfig } from "@/core/network/requests";
 import { isPlatformMac } from "@/core/hotkeys/shortcuts";
 import { Button } from "@/components/ui/button";
@@ -32,7 +32,7 @@ export const KeyboardShortcuts: React.FC = () => {
     null,
   );
   const [newShortcut, setNewShortcut] = useState<string[]>([]);
-  const [config, setConfig] = useUserConfig();
+  const [config, setConfig] = useResolvedMarimoConfig();
   const hotkeys = useAtomValue(hotkeysAtom);
 
   useHotkey("global.showHelp", () => setIsOpen((v) => !v));
diff --git a/frontend/src/components/editor/package-alert.tsx b/frontend/src/components/editor/package-alert.tsx
index eb13a339e3a..8d98cae7fc8 100644
--- a/frontend/src/components/editor/package-alert.tsx
+++ b/frontend/src/components/editor/package-alert.tsx
@@ -36,7 +36,7 @@ import type React from "react";
 import { Button } from "../ui/button";
 import type { PackageInstallationStatus } from "@/core/kernel/messages";
 import { logNever } from "@/utils/assertNever";
-import { useUserConfig } from "@/core/config/config";
+import { useResolvedMarimoConfig } from "@/core/config/config";
 import { isWasm } from "@/core/wasm/utils";
 import {
   type PackageManagerName,
@@ -51,7 +51,7 @@ import { cleanPythonModuleName, reverseSemverSort } from "@/utils/versions";
 export const PackageAlert: React.FC = () => {
   const { packageAlert } = useAlerts();
   const { clearPackageAlert } = useAlertActions();
-  const [userConfig] = useUserConfig();
+  const [userConfig] = useResolvedMarimoConfig();
   const [desiredPackageVersions, setDesiredPackageVersions] = useState<
     Record<string, string>
   >({});
@@ -298,7 +298,7 @@ const InstallPackagesButton = ({
 };
 
 const PackageManagerForm: React.FC = () => {
-  const [config, setConfig] = useUserConfig();
+  const [config, setConfig] = useResolvedMarimoConfig();
 
   // Create form
   const form = useForm<UserConfig>({
diff --git a/frontend/src/components/editor/renderers/vertical-layout/vertical-layout.tsx b/frontend/src/components/editor/renderers/vertical-layout/vertical-layout.tsx
index f1340f77f69..239030b80a9 100644
--- a/frontend/src/components/editor/renderers/vertical-layout/vertical-layout.tsx
+++ b/frontend/src/components/editor/renderers/vertical-layout/vertical-layout.tsx
@@ -36,7 +36,7 @@ import type { CellConfig } from "@/core/network/types";
 import { useAtomValue } from "jotai";
 import { FloatingOutline } from "../../chrome/panels/outline/floating-outline";
 import { KnownQueryParams } from "@/core/constants";
-import { useUserConfig } from "@/core/config/config";
+import { useResolvedMarimoConfig } from "@/core/config/config";
 import { MarkdownLanguageAdapter } from "@/core/codemirror/language/markdown";
 import { isErrorMime } from "@/core/mime";
 
@@ -50,7 +50,7 @@ const VerticalLayoutRenderer: React.FC<VerticalLayoutProps> = ({
 }) => {
   const { invisible } = useDelayVisibility(cells.length, mode);
   const kioskMode = useAtomValue(kioskModeAtom);
-  const [userConfig] = useUserConfig();
+  const [userConfig] = useResolvedMarimoConfig();
 
   const urlParams = new URLSearchParams(window.location.search);
   const showCodeDefault = urlParams.get(KnownQueryParams.showCode);
diff --git a/frontend/src/components/scratchpad/scratchpad.tsx b/frontend/src/components/scratchpad/scratchpad.tsx
index 292e9b0be2c..ba5bf8a18a8 100644
--- a/frontend/src/components/scratchpad/scratchpad.tsx
+++ b/frontend/src/components/scratchpad/scratchpad.tsx
@@ -1,6 +1,6 @@
 /* Copyright 2024 Marimo. All rights reserved. */
 import type React from "react";
-import { useUserConfig } from "@/core/config/config";
+import { useResolvedMarimoConfig } from "@/core/config/config";
 import { useRef, useState } from "react";
 import { useTheme } from "@/theme/useTheme";
 import { CellEditor } from "../editor/cell/code/cell-editor";
@@ -47,7 +47,7 @@ const scratchpadCellConfig: CellConfig = {
 
 export const ScratchPad: React.FC = () => {
   const notebookState = useNotebook();
-  const [userConfig] = useUserConfig();
+  const [userConfig] = useResolvedMarimoConfig();
   const { theme } = useTheme();
   const ref = useRef<EditorView | null>(null);
   const lastFocusedCellId = useLastFocusedCellId();
diff --git a/frontend/src/core/MarimoApp.tsx b/frontend/src/core/MarimoApp.tsx
index 31920c2405d..be6b6a3db2a 100644
--- a/frontend/src/core/MarimoApp.tsx
+++ b/frontend/src/core/MarimoApp.tsx
@@ -9,7 +9,7 @@ import { ErrorBoundary } from "../components/editor/boundary/ErrorBoundary";
 import { TooltipProvider } from "../components/ui/tooltip";
 import { Toaster } from "../components/ui/toaster";
 import { ModalProvider } from "../components/modal/ImperativeModal";
-import { useAppConfig, useUserConfig } from "@/core/config/config";
+import { useAppConfig, useResolvedMarimoConfig } from "@/core/config/config";
 import { initialMode } from "./mode";
 import { CssVariables } from "@/theme/ThemeProvider";
 import { TailwindIndicator } from "@/components/debug/indicator";
@@ -48,7 +48,7 @@ preload(initialMode);
  * The root component of the Marimo app.
  */
 export const MarimoApp: React.FC = memo(() => {
-  const [userConfig] = useUserConfig();
+  const [userConfig] = useResolvedMarimoConfig();
   const [appConfig] = useAppConfig();
   const editorFontSize = toRem(userConfig.display.code_editor_font_size);
 
diff --git a/frontend/src/core/cells/cells.ts b/frontend/src/core/cells/cells.ts
index 9b01ecd7eb7..ff2f8443728 100644
--- a/frontend/src/core/cells/cells.ts
+++ b/frontend/src/core/cells/cells.ts
@@ -40,7 +40,7 @@ import type {
   RuntimeState,
   UpdateCellIdsRequest,
 } from "../network/types";
-import { getUserConfig } from "@/core/config/config";
+import { getResolvedMarimoConfig } from "@/core/config/config";
 import { syncCellIds } from "../network/requests";
 import { kioskModeAtom } from "../mode";
 import {
@@ -1319,7 +1319,7 @@ export function isUninstantiated(
  * Cells that are stale and can be run.
  */
 export function staleCellIds(state: NotebookState) {
-  const autoInstantiate = getUserConfig().runtime.auto_instantiate;
+  const autoInstantiate = getResolvedMarimoConfig().runtime.auto_instantiate;
 
   const { cellIds, cellData, cellRuntime } = state;
   return cellIds.inOrderIds.filter(
diff --git a/frontend/src/core/codemirror/copilot/state.ts b/frontend/src/core/codemirror/copilot/state.ts
index 32a5ea45498..aa61cfcc684 100644
--- a/frontend/src/core/codemirror/copilot/state.ts
+++ b/frontend/src/core/codemirror/copilot/state.ts
@@ -1,5 +1,8 @@
 /* Copyright 2024 Marimo. All rights reserved. */
-import { getUserConfig, userConfigAtom } from "@/core/config/config";
+import {
+  getResolvedMarimoConfig,
+  resolvedMarimoConfigAtom,
+} from "@/core/config/config";
 import { store, waitFor } from "@/core/state/jotai";
 import { atom } from "jotai";
 import { atomWithStorage } from "jotai/utils";
@@ -40,12 +43,12 @@ function getIsLastSignedIn() {
 
 export function isCopilotEnabled() {
   const copilot = getIsLastSignedIn();
-  const userConfig = getUserConfig();
+  const userConfig = getResolvedMarimoConfig();
   return copilot && userConfig.completion.copilot === "github";
 }
 
 export function waitForEnabledCopilot() {
-  return waitFor(userConfigAtom, (value) => {
+  return waitFor(resolvedMarimoConfigAtom, (value) => {
     return value.completion.copilot === "github";
   });
 }
diff --git a/frontend/src/core/codemirror/format.ts b/frontend/src/core/codemirror/format.ts
index 24f1bfc834f..32932307e71 100644
--- a/frontend/src/core/codemirror/format.ts
+++ b/frontend/src/core/codemirror/format.ts
@@ -11,7 +11,7 @@ import {
   updateEditorCodeFromPython,
 } from "./language/utils";
 import { StateEffect } from "@codemirror/state";
-import { getUserConfig } from "../config/config";
+import { getResolvedMarimoConfig } from "../config/config";
 
 export const formattingChangeEffect = StateEffect.define<boolean>();
 
@@ -27,7 +27,7 @@ export async function formatEditorViews(
 
   const formatResponse = await sendFormat({
     codes,
-    lineLength: getUserConfig().formatting.line_length,
+    lineLength: getResolvedMarimoConfig().formatting.line_length,
   });
 
   for (const [cellIdString, formattedCode] of Objects.entries(
diff --git a/frontend/src/core/config/__tests__/config-schema.test.ts b/frontend/src/core/config/__tests__/config-schema.test.ts
index 8b2cf048051..48456c4e5de 100644
--- a/frontend/src/core/config/__tests__/config-schema.test.ts
+++ b/frontend/src/core/config/__tests__/config-schema.test.ts
@@ -1,6 +1,14 @@
 /* Copyright 2024 Marimo. All rights reserved. */
 import { expect, test } from "vitest";
-import { AppConfigSchema, UserConfigSchema } from "../config-schema";
+import { createStore } from "jotai";
+import {
+  AppConfigSchema,
+  type UserConfig,
+  UserConfigSchema,
+} from "../config-schema";
+import { resolvedMarimoConfigAtom } from "../config";
+import { userConfigAtom } from "../config";
+import { configOverridesAtom } from "../config";
 
 test("default AppConfig", () => {
   const defaultConfig = AppConfigSchema.parse({});
@@ -133,3 +141,48 @@ test("default UserConfig - one level", () => {
     }),
   ).toEqual(UserConfigSchema.parse({}));
 });
+
+test("resolvedMarimoConfigAtom overrides correctly and does not mutate the original array", () => {
+  const initialUserConfig = {
+    completion: { activate_on_typing: true, copilot: false },
+    save: {
+      autosave: "after_delay",
+      autosave_delay: 1000,
+      format_on_save: false,
+    },
+    formatting: { line_length: 79 },
+  };
+
+  const overrides = {
+    completion: { copilot: "github" },
+    display: { theme: "dark" },
+  };
+
+  const store = createStore();
+
+  store.set(userConfigAtom, initialUserConfig as UserConfig);
+  store.set(configOverridesAtom, overrides);
+
+  const result = store.get(resolvedMarimoConfigAtom);
+
+  expect(result).toEqual({
+    completion: { activate_on_typing: true, copilot: "github" },
+    save: {
+      autosave: "after_delay",
+      autosave_delay: 1000,
+      format_on_save: false,
+    },
+    formatting: { line_length: 79 },
+    display: { theme: "dark" },
+  });
+
+  expect(initialUserConfig).toEqual({
+    completion: { activate_on_typing: true, copilot: false },
+    save: {
+      autosave: "after_delay",
+      autosave_delay: 1000,
+      format_on_save: false,
+    },
+    formatting: { line_length: 79 },
+  });
+});
diff --git a/frontend/src/core/config/config-schema.ts b/frontend/src/core/config/config-schema.ts
index 82dd4f11d46..dc047f0c8c7 100644
--- a/frontend/src/core/config/config-schema.ts
+++ b/frontend/src/core/config/config-schema.ts
@@ -1,8 +1,13 @@
 /* Copyright 2024 Marimo. All rights reserved. */
 import { z } from "zod";
 import { Logger } from "@/utils/Logger";
-import { getMarimoAppConfig, getMarimoUserConfig } from "../dom/marimo-tag";
+import {
+  getMarimoAppConfig,
+  getMarimoConfigOverrides,
+  getMarimoUserConfig,
+} from "../dom/marimo-tag";
 import type { MarimoConfig } from "../network/types";
+import { invariant } from "@/utils/invariant";
 
 // This has to be defined in the same file as the zod schema to satisfy zod
 export const PackageManagerNames = [
@@ -201,6 +206,23 @@ export function parseUserConfig(): UserConfig {
   }
 }
 
+export function parseConfigOverrides(): {} {
+  try {
+    const overrides = getMarimoConfigOverrides() as {};
+    invariant(
+      typeof overrides === "object",
+      "internal-error: marimo-config-overrides is not an object",
+    );
+    if (Object.keys(overrides).length > 0) {
+      Logger.log("🔧 Project configuration overrides:", overrides);
+    }
+    return overrides as {};
+  } catch (error) {
+    Logger.error(`Marimo got an unexpected configuration overrides: ${error}`);
+    return {};
+  }
+}
+
 export function defaultUserConfig(): UserConfig {
   return UserConfigSchema.parse({}) as unknown as UserConfig;
 }
diff --git a/frontend/src/core/config/config.ts b/frontend/src/core/config/config.ts
index 7043bf9cda2..38d7412a2e1 100644
--- a/frontend/src/core/config/config.ts
+++ b/frontend/src/core/config/config.ts
@@ -1,25 +1,35 @@
 /* Copyright 2024 Marimo. All rights reserved. */
-import { atom, useAtom, useSetAtom } from "jotai";
+import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
 import {
   type AppConfig,
   type UserConfig,
   parseAppConfig,
+  parseConfigOverrides,
   parseUserConfig,
 } from "./config-schema";
 import { store } from "../state/jotai";
 import { OverridingHotkeyProvider } from "../hotkeys/hotkeys";
+import { merge } from "lodash-es";
 
 /**
  * Atom for storing the user config.
  */
 export const userConfigAtom = atom<UserConfig>(parseUserConfig());
 
+export const configOverridesAtom = atom<{}>(parseConfigOverrides());
+
+export const resolvedMarimoConfigAtom = atom<UserConfig>((get) => {
+  const overrides = get(configOverridesAtom);
+  const userConfig = get(userConfigAtom);
+  return merge({}, userConfig, overrides);
+});
+
 export const autoInstantiateAtom = atom((get) => {
-  return get(userConfigAtom).runtime.auto_instantiate;
+  return get(resolvedMarimoConfigAtom).runtime.auto_instantiate;
 });
 
 export const hotkeyOverridesAtom = atom((get) => {
-  return get(userConfigAtom).keymap.overrides ?? {};
+  return get(resolvedMarimoConfigAtom).keymap.overrides ?? {};
 });
 
 export const hotkeysAtom = atom((get) => {
@@ -34,12 +44,23 @@ export function useUserConfig() {
   return useAtom(userConfigAtom);
 }
 
-export function getUserConfig() {
-  return store.get(userConfigAtom);
+export function useResolvedMarimoConfig() {
+  return [
+    useAtomValue(resolvedMarimoConfigAtom),
+    useSetAtom(userConfigAtom),
+  ] as const;
+}
+
+export function getResolvedMarimoConfig() {
+  return store.get(resolvedMarimoConfigAtom);
 }
 
 export const aiEnabledAtom = atom<boolean>((get) => {
-  return isAiEnabled(get(userConfigAtom));
+  return isAiEnabled(get(resolvedMarimoConfigAtom));
+});
+
+export const editorFontSizeAtom = atom<number>((get) => {
+  return get(resolvedMarimoConfigAtom).display.code_editor_font_size;
 });
 
 export function isAiEnabled(config: UserConfig) {
diff --git a/frontend/src/core/config/feature-flag.tsx b/frontend/src/core/config/feature-flag.tsx
index a228f1436ac..c2823640c94 100644
--- a/frontend/src/core/config/feature-flag.tsx
+++ b/frontend/src/core/config/feature-flag.tsx
@@ -3,7 +3,7 @@
 import { repl } from "@/utils/repl";
 import type { UserConfig } from "vite";
 import { saveUserConfig } from "../network/requests";
-import { getUserConfig } from "./config";
+import { getResolvedMarimoConfig } from "./config";
 
 // eslint-disable-next-line @typescript-eslint/no-empty-interface
 export interface ExperimentalFeatures {
@@ -27,8 +27,9 @@ export function getFeatureFlag<T extends keyof ExperimentalFeatures>(
   feature: T,
 ): ExperimentalFeatures[T] {
   return (
-    (getUserConfig().experimental?.[feature] as ExperimentalFeatures[T]) ??
-    defaultValues[feature]
+    (getResolvedMarimoConfig().experimental?.[
+      feature
+    ] as ExperimentalFeatures[T]) ?? defaultValues[feature]
   );
 }
 
@@ -36,7 +37,7 @@ function setFeatureFlag(
   feature: keyof UserConfig["experimental"],
   value: boolean,
 ) {
-  const userConfig = getUserConfig();
+  const userConfig = getResolvedMarimoConfig();
   userConfig.experimental = userConfig.experimental ?? {};
   userConfig.experimental[feature] = value;
   saveUserConfig({ config: userConfig });
diff --git a/frontend/src/core/dom/marimo-tag.ts b/frontend/src/core/dom/marimo-tag.ts
index 2275769f571..5a65d15139b 100644
--- a/frontend/src/core/dom/marimo-tag.ts
+++ b/frontend/src/core/dom/marimo-tag.ts
@@ -8,6 +8,7 @@ interface MarimoSettings {
   getMarimoServerToken: () => string;
   getMarimoAppConfig: () => unknown;
   getMarimoUserConfig: () => unknown;
+  getMarimoConfigOverrides: () => unknown;
   getMarimoCode: () => string;
 }
 
@@ -24,6 +25,13 @@ const domBasedMarimoSettings: MarimoSettings = {
   getMarimoUserConfig: () => {
     return JSON.parse(getMarimoDOMValue("marimo-user-config", "config"));
   },
+  getMarimoConfigOverrides: () => {
+    try {
+      return JSON.parse(getMarimoDOMValue("marimo-user-config", "overrides"));
+    } catch {
+      return {};
+    }
+  },
   getMarimoCode: () => {
     const tag = document.querySelector("marimo-code");
     invariant(tag, "internal-error: marimo-code not tag not found");
@@ -40,6 +48,9 @@ const islandsBasedMarimoSettings: MarimoSettings = {
   getMarimoServerToken: () => {
     return "";
   },
+  getMarimoConfigOverrides: () => {
+    return {};
+  },
   getMarimoAppConfig: () => {
     return {};
   },
@@ -56,6 +67,7 @@ export const {
   getMarimoServerToken,
   getMarimoAppConfig,
   getMarimoUserConfig,
+  getMarimoConfigOverrides,
   getMarimoCode,
 } = isIslands() ? islandsBasedMarimoSettings : domBasedMarimoSettings;
 
diff --git a/frontend/src/theme/useTheme.ts b/frontend/src/theme/useTheme.ts
index 0f8cce16370..48ca30e5c87 100644
--- a/frontend/src/theme/useTheme.ts
+++ b/frontend/src/theme/useTheme.ts
@@ -1,7 +1,7 @@
 /* Copyright 2024 Marimo. All rights reserved. */
 import { store } from "@/core/state/jotai";
 import { atom, useAtomValue } from "jotai";
-import { userConfigAtom } from "@/core/config/config";
+import { resolvedMarimoConfigAtom } from "@/core/config/config";
 import { isIslands } from "@/core/islands/utils";
 
 export type Theme = "light" | "dark" | "system";
@@ -51,7 +51,7 @@ const themeAtom = atom((get) => {
     return "light";
   }
 
-  return get(userConfigAtom).display.theme;
+  return get(resolvedMarimoConfigAtom).display.theme;
 });
 
 const prefersDarkModeAtom = atom(false);
diff --git a/marimo/_ai/llm.py b/marimo/_ai/llm.py
index 9d100990a47..f9afa5deadb 100644
--- a/marimo/_ai/llm.py
+++ b/marimo/_ai/llm.py
@@ -87,7 +87,7 @@ def _require_api_key(self) -> str:
         try:
             from marimo._runtime.context.types import get_context
 
-            api_key = get_context().user_config["ai"]["open_ai"]["api_key"]
+            api_key = get_context().marimo_config["ai"]["open_ai"]["api_key"]
             if api_key:
                 return api_key
         except Exception:
@@ -174,7 +174,7 @@ def _require_api_key(self) -> str:
         try:
             from marimo._runtime.context.types import get_context
 
-            api_key = get_context().user_config["ai"]["anthropic"]["api_key"]
+            api_key = get_context().marimo_config["ai"]["anthropic"]["api_key"]
             if api_key:
                 return api_key
         except Exception:
@@ -268,7 +268,7 @@ def _require_api_key(self) -> str:
         try:
             from marimo._runtime.context.types import get_context
 
-            api_key = get_context().user_config["ai"]["google"]["api_key"]
+            api_key = get_context().marimo_config["ai"]["google"]["api_key"]
             if api_key:
                 return api_key
         except Exception:
diff --git a/marimo/_ast/app.py b/marimo/_ast/app.py
index 046871a6cad..804b1375ee0 100644
--- a/marimo/_ast/app.py
+++ b/marimo/_ast/app.py
@@ -27,8 +27,7 @@
     MultipleDefinitionError,
     UnparsableError,
 )
-from marimo._config.config import MarimoConfig, WidthType
-from marimo._config.utils import load_config
+from marimo._config.config import WidthType
 from marimo._messaging.mimetypes import KnownMimeType
 from marimo._output.hypertext import Html
 from marimo._output.rich_help import mddoc
@@ -159,8 +158,7 @@ def __init__(self, **kwargs: Any) -> None:
         # Take `AppConfig` as kwargs for forward/backward compatibility;
         # unrecognized settings will just be dropped, instead of raising
         # a TypeError.
-        self._config = _AppConfig.from_untrusted_dict(kwargs)
-        self._user_config = load_config()
+        self._config: _AppConfig = _AppConfig.from_untrusted_dict(kwargs)
 
         if runtime_context_installed():
             # nested applications get a unique cell prefix to disambiguate
@@ -603,10 +601,6 @@ def __init__(self, app: App) -> None:
     def config(self) -> _AppConfig:
         return self._app._config
 
-    @property
-    def user_config(self) -> MarimoConfig:
-        return self._app._user_config
-
     @property
     def cell_manager(self) -> CellManager:
         return self._app._cell_manager
diff --git a/marimo/_cli/config/commands.py b/marimo/_cli/config/commands.py
index fce7e8fd0d1..437a717c86d 100644
--- a/marimo/_cli/config/commands.py
+++ b/marimo/_cli/config/commands.py
@@ -1,11 +1,17 @@
 # Copyright 2024 Marimo. All rights reserved.
 from __future__ import annotations
 
+import os
+
 import click
 
 from marimo._cli.config.utils import highlight_toml_headers
 from marimo._cli.print import echo, green
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import (
+    UserConfigManager,
+    get_default_config_manager,
+)
+from marimo._config.reader import find_nearest_pyproject_toml
 
 
 @click.group(help="""Various commands for the marimo config.""")
@@ -23,12 +29,96 @@ def show() -> None:
     """
     import tomlkit
 
-    config_manager = UserConfigManager()
+    current_path = os.getcwd()
+
+    config_manager = get_default_config_manager(current_path=current_path)
     # Save config if doesn't exist
-    config_manager.save_config_if_missing()
-    echo(f"User config from {green(config_manager.get_config_path())}\n")
-    toml_string = tomlkit.dumps(config_manager.get_config())
-    echo(highlight_toml_headers(toml_string))
+    UserConfigManager().save_config_if_missing()
+
+    # Show project overrides if they exist
+    project_config_path = find_nearest_pyproject_toml(current_path)
+    overrides = config_manager.get_config_overrides()
+    if overrides:
+        echo(
+            f"\n📁 Project overrides from {green(str(project_config_path))}\n"
+        )
+        echo(highlight_toml_headers(tomlkit.dumps(overrides)))
+
+    # Show user config
+    user_config_path = config_manager.user_config_mgr.get_config_path()
+    echo(f"\n🏠 User config from {green(user_config_path)}\n")
+    user_config = config_manager.get_user_config()
+    echo(highlight_toml_headers(tomlkit.dumps(user_config)))
+
+
+@click.command(help="""Describe the marimo config""")
+def describe() -> None:
+    """Print documentation for all config options."""
+    import inspect
+    from textwrap import indent
+
+    import marimo._config.config as marimo_config
+    from marimo._cli.print import echo, green, muted, yellow
+
+    def format_type_docs(cls: type, indent_level: int = 0) -> str:
+        """Recursively format type documentation."""
+        output: list[str] = []
+        # Get annotations and docs from the class
+        annotations = (
+            cls.__annotations__ if hasattr(cls, "__annotations__") else {}
+        )
+
+        # Get the docstring
+        doc = inspect.getdoc(cls)
+        if doc and indent_level == 0:
+            output.append(muted(f"# {doc}\n"))
+
+        for key, type_hint in annotations.items():
+            # Get field documentation if available
+            if hasattr(type_hint, "__forward_arg__"):
+                type_hint = type_hint.__forward_arg__
+                # Import from marimo._config.config
+                # If NotRequired, we need to get the type from the NotRequired
+                if str(type_hint).startswith("NotRequired["):
+                    type_hint = type_hint[12:-1]
+
+                if type_hint in marimo_config.__dict__ and "TypedDict" in str(
+                    type(marimo_config.__dict__[type_hint])
+                ):
+                    config = marimo_config.__dict__[type_hint]
+                    field_output = f"[{green(key)}]"
+                    output.append(indent(field_output, "  " * indent_level))
+                    # Get the docstring for the nested TypedDict
+                    nested_doc = inspect.getdoc(config)
+                    if nested_doc:
+                        output.append(
+                            indent(
+                                muted(f"# {nested_doc}"),
+                                "  " * (indent_level + 1),
+                            )
+                        )
+                        output.append("")
+                    output.append(
+                        indent(
+                            format_type_docs(config, indent_level + 1),
+                            "  " * indent_level,
+                        )
+                    )
+                else:
+                    output.append(
+                        indent(
+                            f"{yellow(key)} = {type_hint}", "  " * indent_level
+                        )
+                    )
+                    output.append("")
+            else:
+                field_output = f"{yellow(key)}: {type_hint}"
+                output.append(indent(field_output, "  " * indent_level))
+
+        return "\n".join(output)
+
+    echo(format_type_docs(marimo_config.MarimoConfig))
 
 
 config.add_command(show)
+config.add_command(describe)
diff --git a/marimo/_cli/print.py b/marimo/_cli/print.py
index b2deb47fad8..0cfd8d6545f 100644
--- a/marimo/_cli/print.py
+++ b/marimo/_cli/print.py
@@ -18,6 +18,11 @@ def green(text: str, bold: bool = False) -> str:
     return prefix + text + "\033[0m"
 
 
+def yellow(text: str, bold: bool = False) -> str:
+    prefix = "\033[33m" if not bold else "\033[1;33m"
+    return prefix + text + "\033[0m"
+
+
 def orange(text: str, bold: bool = False) -> str:
     prefix = "\033[33m" if not bold else "\033[1;33m"
     return prefix + text + "\033[0m"
diff --git a/marimo/_config/config.py b/marimo/_config/config.py
index 57dac56e22d..04c073b114b 100644
--- a/marimo/_config/config.py
+++ b/marimo/_config/config.py
@@ -3,9 +3,9 @@
 
 import sys
 from dataclasses import dataclass
-from typing import TypeVar
 
 from marimo._config.packages import infer_package_manager
+from marimo._config.utils import deep_copy
 
 if sys.version_info < (3, 11):
     from typing_extensions import NotRequired
@@ -305,7 +305,7 @@ def merge_config(
     # so that they don't get merged into the new config
     if new_config.get("keymap", {}).get("overrides") is not None:
         # Clone config to avoid modifying the original
-        config = _deep_copy(config)
+        config = deep_copy(config)
         config.get("keymap", {}).pop("overrides", {})
 
     merged = cast(
@@ -316,73 +316,18 @@ def merge_config(
     )
 
     # Patches for backward compatibility
-    if (
-        merged["runtime"]["auto_reload"] is False  # type:ignore[comparison-overlap]
-    ):
-        merged["runtime"]["auto_reload"] = "off"
-    if (
-        merged["runtime"]["auto_reload"] is True  # type:ignore[comparison-overlap]
-    ):
-        merged["runtime"]["auto_reload"] = "lazy"
-    if (
-        merged["runtime"]["auto_reload"] == "detect"  # type:ignore[comparison-overlap]
-    ):
-        merged["runtime"]["auto_reload"] = "lazy"
+    if "runtime" in merged:
+        if (
+            merged["runtime"].get("auto_reload") is False  # type:ignore[comparison-overlap]
+        ):
+            merged["runtime"]["auto_reload"] = "off"
+        elif (
+            merged["runtime"].get("auto_reload") is True  # type:ignore[comparison-overlap]
+        ):
+            merged["runtime"]["auto_reload"] = "lazy"
+        elif (
+            merged["runtime"].get("auto_reload") == "detect"  # type:ignore[comparison-overlap]
+        ):
+            merged["runtime"]["auto_reload"] = "lazy"
 
     return merged
-
-
-def _deep_copy(obj: Any) -> Any:
-    if isinstance(obj, dict):
-        return {k: _deep_copy(v) for k, v in obj.items()}  # type: ignore
-    if isinstance(obj, list):
-        return [_deep_copy(v) for v in obj]  # type: ignore
-    return obj
-
-
-SECRET_PLACEHOLDER = "********"
-
-
-def mask_secrets(config: MarimoConfig | PartialMarimoConfig) -> MarimoConfig:
-    def deep_remove_from_path(path: list[str], obj: Dict[str, Any]) -> None:
-        key = path[0]
-        if key not in obj:
-            return
-        if len(path) == 1:
-            if obj[key]:
-                obj[key] = SECRET_PLACEHOLDER
-        else:
-            deep_remove_from_path(path[1:], cast(Dict[str, Any], obj[key]))
-
-    secrets = [
-        ["ai", "open_ai", "api_key"],
-        ["ai", "anthropic", "api_key"],
-        ["ai", "google", "api_key"],
-    ]
-
-    new_config = _deep_copy(config)
-    for secret in secrets:
-        deep_remove_from_path(secret, cast(Dict[str, Any], new_config))
-
-    return new_config  # type: ignore
-
-
-T = TypeVar("T")
-
-
-def remove_secret_placeholders(config: T) -> T:
-    def deep_remove(obj: Any) -> Any:
-        if isinstance(obj, dict):
-            # Filter all keys with value SECRET_PLACEHOLDER
-            return {
-                k: deep_remove(v)
-                for k, v in obj.items()
-                if v != SECRET_PLACEHOLDER
-            }  # type: ignore
-        if isinstance(obj, list):
-            return [deep_remove(v) for v in obj]  # type: ignore
-        if obj == SECRET_PLACEHOLDER:
-            return None
-        return obj
-
-    return deep_remove(_deep_copy(config))  # type: ignore
diff --git a/marimo/_config/manager.py b/marimo/_config/manager.py
index 4d4b7121a4c..33c52d9e243 100644
--- a/marimo/_config/manager.py
+++ b/marimo/_config/manager.py
@@ -2,33 +2,131 @@
 from __future__ import annotations
 
 import os
-from typing import Optional
+from abc import abstractmethod
+from typing import Optional, Union, cast
 
 from marimo import _loggers
 from marimo._config.config import (
     DEFAULT_CONFIG,
     MarimoConfig,
     PartialMarimoConfig,
-    mask_secrets,
     merge_config,
     merge_default_config,
-    remove_secret_placeholders,
 )
+from marimo._config.reader import read_marimo_config, read_pyproject_config
+from marimo._config.secrets import mask_secrets, remove_secret_placeholders
 from marimo._config.utils import (
-    get_or_create_config_path,
-    load_config,
+    get_or_create_user_config_path,
 )
 
 LOGGER = _loggers.marimo_logger()
 
 
-class UserConfigManager:
-    def __init__(self, config_path: Optional[str] = None) -> None:
-        self._config_path = config_path
-        self._config = load_config()
+def get_default_config_manager(
+    *, current_path: Optional[str]
+) -> MarimoConfigManager:
+    # Current path should be the notebook file
+    # If it's not known, use the current working directory
+    if current_path is None:
+        current_path = os.getcwd()
+    return MarimoConfigManager(
+        UserConfigManager(), ProjectConfigManager(current_path)
+    )
+
+
+@abstractmethod
+class MarimoConfigReader:
+    @abstractmethod
+    def get_config(self, *, hide_secrets: bool = True) -> MarimoConfig:
+        """Get the configuration, optionally hiding secrets"""
+        pass
+
+
+class MarimoConfigManager(MarimoConfigReader):
+    def __init__(
+        self,
+        user_config_mgr: UserConfigManager,
+        *partials: PartialMarimoConfigReader,
+    ) -> None:
+        self.user_config_mgr = user_config_mgr
+        self.partials = partials
+
+    def get_user_config(self, *, hide_secrets: bool = True) -> MarimoConfig:
+        """Get the user configuration"""
+        return self.user_config_mgr.get_config(hide_secrets=hide_secrets)
+
+    def get_config_overrides(
+        self, *, hide_secrets: bool = True
+    ) -> PartialMarimoConfig:
+        """Get the configuration overrides"""
+        if not self.partials:
+            return {}
+        if len(self.partials) == 1:
+            return self.partials[0].get_config(hide_secrets=hide_secrets)
+        result: MarimoConfig = cast(MarimoConfig, {})
+        for partial in self.partials:
+            result = merge_config(
+                result, partial.get_config(hide_secrets=hide_secrets)
+            )
+        return cast(PartialMarimoConfig, result)
+
+    def get_config(self, *, hide_secrets: bool = True) -> MarimoConfig:
+        """Get the configuration, by merging the user configuration and the configuration overrides"""
+        return merge_config(
+            self.get_user_config(hide_secrets=hide_secrets),
+            self.get_config_overrides(hide_secrets=hide_secrets),
+        )
 
     def save_config(
-        self, config: MarimoConfig | PartialMarimoConfig
+        self, config: Union[MarimoConfig, PartialMarimoConfig]
+    ) -> MarimoConfig:
+        """Save the configuration"""
+        return self.user_config_mgr.save_config(config)
+
+    def with_overrides(
+        self, overrides: PartialMarimoConfig
+    ) -> MarimoConfigManager:
+        """Get a new config manager with the given overrides"""
+        return MarimoConfigManager(
+            self.user_config_mgr,
+            *self.partials,
+            MarimoConfigReaderWithOverrides(overrides),
+        )
+
+
+@abstractmethod
+class PartialMarimoConfigReader:
+    @abstractmethod
+    def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig:
+        """Get the configuration, as a partial configuration"""
+        pass
+
+
+class ProjectConfigManager(PartialMarimoConfigReader):
+    """Read the project configuration"""
+
+    def __init__(self, start_path: str) -> None:
+        self.start_path = start_path
+
+    def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig:
+        try:
+            project_config = read_pyproject_config(self.start_path)
+            if project_config is None:
+                return {}
+        except Exception as e:
+            LOGGER.warning("Failed to read project config: %s", e)
+            return {}
+
+        if hide_secrets:
+            return cast(PartialMarimoConfig, mask_secrets(project_config))
+        return project_config
+
+
+class UserConfigManager(MarimoConfigReader):
+    """Read and write the user configuration"""
+
+    def save_config(
+        self, config: Union[MarimoConfig, PartialMarimoConfig]
     ) -> MarimoConfig:
         import tomlkit
 
@@ -37,13 +135,13 @@ def save_config(
         # Remove the secret placeholders from the incoming config
         config = remove_secret_placeholders(config)
         # Merge the current config with the new config
-        merged = merge_config(self._config, config)
+        current_config = self._load_config()
+        merged = merge_config(current_config, config)
 
         with open(config_path, "w", encoding="utf-8") as f:
             tomlkit.dump(merged, f)
 
-        self._config = merge_default_config(merged)
-        return self._config
+        return merge_default_config(merged)
 
     def save_config_if_missing(self) -> None:
         try:
@@ -53,23 +151,50 @@ def save_config_if_missing(self) -> None:
         except Exception as e:
             LOGGER.warning("Failed to save config: %s", e)
 
-    def get_config(self, hide_secrets: bool = True) -> MarimoConfig:
+    def get_config(self, *, hide_secrets: bool = True) -> MarimoConfig:
+        current_config = self._load_config()
         if hide_secrets:
-            return mask_secrets(self._config)
-        return self._config
+            return mask_secrets(current_config)
+        return current_config
 
     def get_config_path(self) -> str:
-        return get_or_create_config_path()
+        return get_or_create_user_config_path()
 
+    def _load_config(self) -> MarimoConfig:
+        """
+        Load configuration, taking into account user config file, if any.
+        """
+        try:
+            path = self.get_config_path()
+        except OSError as e:
+            path = None
+            LOGGER.warning(
+                "Encountered error when searching for config: %s", str(e)
+            )
 
-class UserConfigManagerWithOverride(UserConfigManager):
-    def __init__(
-        self, delegate: UserConfigManager, override_config: PartialMarimoConfig
-    ) -> None:
-        self.delegate = delegate
+        if path is not None:
+            LOGGER.debug("Using config at %s", path)
+            try:
+                user_config = read_marimo_config(path)
+            except Exception as e:
+                LOGGER.error("Failed to read user config at %s", path)
+                LOGGER.error(str(e))
+                return DEFAULT_CONFIG
+            return merge_default_config(user_config)
+        else:
+            LOGGER.debug("No config found; loading default settings.")
+        return DEFAULT_CONFIG
+
+
+class MarimoConfigReaderWithOverrides(PartialMarimoConfigReader):
+    """Read the configuration, with overrides"""
+
+    def __init__(self, override_config: PartialMarimoConfig) -> None:
         self.override_config = override_config
 
-    def get_config(self, hide_secrets: bool = True) -> MarimoConfig:
-        return merge_config(
-            self.delegate.get_config(hide_secrets), self.override_config
-        )
+    def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig:
+        if hide_secrets:
+            return cast(
+                PartialMarimoConfig, mask_secrets(self.override_config)
+            )
+        return self.override_config
diff --git a/marimo/_config/reader.py b/marimo/_config/reader.py
new file mode 100644
index 00000000000..8021a97a479
--- /dev/null
+++ b/marimo/_config/reader.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+from typing import Any, Dict, Optional, Union, cast
+
+from marimo import _loggers
+from marimo._config.config import PartialMarimoConfig
+
+LOGGER = _loggers.marimo_logger()
+
+
+def read_toml(file_path: Union[str, Path]) -> Dict[str, Any]:
+    """Read and parse a TOML file."""
+    import tomlkit
+
+    with open(file_path, "rb") as file:
+        return tomlkit.load(file)
+
+
+def read_marimo_config(path: str) -> PartialMarimoConfig:
+    """Read the marimo.toml configuration."""
+    return cast(PartialMarimoConfig, read_toml(path))
+
+
+def read_pyproject_config(
+    start_path: Union[str, Path],
+) -> Optional[PartialMarimoConfig]:
+    """Read the pyproject.toml configuration."""
+    path = find_nearest_pyproject_toml(start_path)
+    if path is None:
+        return None
+    pyproject_config = read_toml(path)
+    marimo_tool_config = pyproject_config.get("tool", {}).get("marimo", None)
+    if marimo_tool_config is None:
+        return None
+    if not isinstance(marimo_tool_config, dict):
+        LOGGER.warning(
+            "pyproject.toml contains invalid marimo config: %s",
+            marimo_tool_config,
+        )
+        return None
+    LOGGER.debug("Found marimo config in pyproject.toml at %s", path)
+    return cast(PartialMarimoConfig, marimo_tool_config)
+
+
+def find_nearest_pyproject_toml(
+    start_path: Union[str, Path],
+) -> Optional[Path]:
+    """Find the nearest pyproject.toml file."""
+    path = Path(start_path).resolve()
+    root = path.anchor
+    while not path.joinpath("pyproject.toml").exists():
+        if str(path) == root:
+            return None
+        if path.parent == path:
+            return None
+        path = path.parent
+    return path.joinpath("pyproject.toml")
diff --git a/marimo/_config/secrets.py b/marimo/_config/secrets.py
new file mode 100644
index 00000000000..246de03cb03
--- /dev/null
+++ b/marimo/_config/secrets.py
@@ -0,0 +1,53 @@
+from typing import Any, Dict, TypeVar, Union, cast
+
+from marimo._config.config import MarimoConfig, PartialMarimoConfig
+from marimo._config.utils import deep_copy
+
+SECRET_PLACEHOLDER = "********"
+
+
+def mask_secrets(
+    config: Union[MarimoConfig, PartialMarimoConfig],
+) -> MarimoConfig:
+    def deep_remove_from_path(path: list[str], obj: Dict[str, Any]) -> None:
+        key = path[0]
+        if key not in obj:
+            return
+        if len(path) == 1:
+            if obj[key]:
+                obj[key] = SECRET_PLACEHOLDER
+        else:
+            deep_remove_from_path(path[1:], cast(Dict[str, Any], obj[key]))
+
+    secrets = [
+        ["ai", "open_ai", "api_key"],
+        ["ai", "anthropic", "api_key"],
+        ["ai", "google", "api_key"],
+    ]
+
+    new_config = deep_copy(config)
+    for secret in secrets:
+        deep_remove_from_path(secret, cast(Dict[str, Any], new_config))
+
+    return new_config  # type: ignore
+
+
+T = TypeVar("T")
+
+
+def remove_secret_placeholders(config: T) -> T:
+    def deep_remove(obj: Any) -> Any:
+        if isinstance(obj, dict):
+            # Filter all keys with value SECRET_PLACEHOLDER
+            return {
+                k: deep_remove(v)
+                for k, v in obj.items()
+                if v != SECRET_PLACEHOLDER
+            }  # type: ignore
+        if isinstance(obj, list):
+            return [deep_remove(v) for v in obj]  # type: ignore
+        if obj == SECRET_PLACEHOLDER:
+            return None
+        return obj
+
+    return deep_remove(deep_copy(config))  # type: ignore
diff --git a/marimo/_config/utils.py b/marimo/_config/utils.py
index 4393930ca53..699d6a879bf 100644
--- a/marimo/_config/utils.py
+++ b/marimo/_config/utils.py
@@ -2,15 +2,9 @@
 from __future__ import annotations
 
 import os
-from typing import Optional, cast
+from typing import Any, Optional
 
 from marimo import _loggers
-from marimo._config.config import (
-    DEFAULT_CONFIG,
-    MarimoConfig,
-    PartialMarimoConfig,
-    merge_default_config,
-)
 
 LOGGER = _loggers.marimo_logger()
 
@@ -45,13 +39,13 @@ def _xdg_config_path() -> str:
     return os.path.join(xdg_config_home, "marimo", "marimo.toml")
 
 
-def get_or_create_config_path() -> str:
+def get_or_create_user_config_path() -> str:
     """Find path of config file, or create it
 
     If no config file is found, one will be created under the proper XDG path
     (i.e. `~/.config/marimo` or `$XDG_CONFIG_HOME/marimo`)
     """
-    current_config_path = get_config_path()
+    current_config_path = get_user_config_path()
     if current_config_path:
         return current_config_path
     else:
@@ -61,7 +55,7 @@ def get_or_create_config_path() -> str:
         return config_path
 
 
-def get_config_path() -> Optional[str]:
+def get_user_config_path() -> Optional[str]:
     """Find path of config file (.marimo.toml).
 
     Searches from current directory to home, return the first config file
@@ -120,27 +114,9 @@ def get_config_path() -> Optional[str]:
     return None
 
 
-def load_config() -> MarimoConfig:
-    """Load configuration, taking into account user config file, if any."""
-    try:
-        path = get_config_path()
-    except OSError as e:
-        path = None
-        msg = "Encountered error when searching for config: %s"
-        LOGGER.warning(msg, str(e))
-
-    if path is not None:
-        LOGGER.debug("Using config at %s", path)
-        try:
-            import tomlkit
-
-            with open(path, "rb") as f:
-                user_config = tomlkit.parse(f.read())
-        except Exception as e:
-            LOGGER.error("Failed to read user config at %s", path)
-            LOGGER.error(str(e))
-            return DEFAULT_CONFIG
-        return merge_default_config(cast(PartialMarimoConfig, user_config))
-    else:
-        LOGGER.debug("No config found; loading default settings.")
-    return DEFAULT_CONFIG
+def deep_copy(obj: Any) -> Any:
+    if isinstance(obj, dict):
+        return {k: deep_copy(v) for k, v in obj.items()}  # type: ignore
+    if isinstance(obj, list):
+        return [deep_copy(v) for v in obj]  # type: ignore
+    return obj
diff --git a/marimo/_output/formatters/df_formatters.py b/marimo/_output/formatters/df_formatters.py
index f87d6785cbf..e836ed5c927 100644
--- a/marimo/_output/formatters/df_formatters.py
+++ b/marimo/_output/formatters/df_formatters.py
@@ -17,7 +17,7 @@ def include_opinionated() -> bool:
 
     if runtime_context_installed():
         ctx = get_context()
-        return ctx.user_config["display"]["dataframes"] == "rich"
+        return ctx.marimo_config["display"]["dataframes"] == "rich"
     return True
 
 
diff --git a/marimo/_runtime/app_meta.py b/marimo/_runtime/app_meta.py
index 3e0b8e87a1e..f277593ed33 100644
--- a/marimo/_runtime/app_meta.py
+++ b/marimo/_runtime/app_meta.py
@@ -3,7 +3,11 @@
 
 from typing import Literal, Optional
 
-from marimo._config.utils import load_config
+from marimo._config.config import DEFAULT_CONFIG
+from marimo._runtime.context.types import (
+    ContextNotInitializedError,
+    get_context,
+)
 from marimo._runtime.context.utils import get_mode
 
 
@@ -15,9 +19,6 @@ class AppMeta:
     its display theme and execution mode.
     """
 
-    def __init__(self) -> None:
-        self.user_config = load_config()
-
     @property
     def theme(self) -> str:
         """The display theme of the app.
@@ -42,7 +43,13 @@ def theme(self) -> str:
 
         - "light" or "dark", indicating the app's display theme
         """
-        theme = self.user_config["display"]["theme"] or "light"
+        try:
+            context = get_context()
+            marimo_config = context.marimo_config
+        except ContextNotInitializedError:
+            marimo_config = DEFAULT_CONFIG
+
+        theme = marimo_config["display"]["theme"] or "light"
         if theme == "system":
             # TODO(mscolnick): have frontend tell the backend the system theme
             return "light"
diff --git a/marimo/_runtime/context/kernel_context.py b/marimo/_runtime/context/kernel_context.py
index 173c503563b..8412269e11b 100644
--- a/marimo/_runtime/context/kernel_context.py
+++ b/marimo/_runtime/context/kernel_context.py
@@ -50,7 +50,7 @@ def execution_context(self) -> ExecutionContext | None:
         return self._kernel.execution_context
 
     @property
-    def user_config(self) -> MarimoConfig:
+    def marimo_config(self) -> MarimoConfig:
         return self._kernel.user_config
 
     @property
diff --git a/marimo/_runtime/context/script_context.py b/marimo/_runtime/context/script_context.py
index 9bc5468dd37..d846bbd22d0 100644
--- a/marimo/_runtime/context/script_context.py
+++ b/marimo/_runtime/context/script_context.py
@@ -8,6 +8,7 @@
 
 from marimo._cli.parse_args import args_from_argv
 from marimo._config.config import MarimoConfig
+from marimo._config.manager import get_default_config_manager
 from marimo._plugins.ui._core.ids import NoIDProviderException
 from marimo._plugins.ui._core.registry import UIElementRegistry
 from marimo._runtime.cell_lifecycle_registry import CellLifecycleRegistry
@@ -59,8 +60,10 @@ def execution_context(self) -> ExecutionContext | None:
         return self._app.execution_context
 
     @property
-    def user_config(self) -> MarimoConfig:
-        return self._app.user_config
+    def marimo_config(self) -> MarimoConfig:
+        return get_default_config_manager(
+            current_path=self.filename
+        ).get_config()
 
     @property
     def cell_id(self) -> Optional[CellId_t]:
diff --git a/marimo/_runtime/context/types.py b/marimo/_runtime/context/types.py
index 4f9949b7f3b..0a87692abcf 100644
--- a/marimo/_runtime/context/types.py
+++ b/marimo/_runtime/context/types.py
@@ -93,7 +93,11 @@ def execution_context(self) -> ExecutionContext | None:
 
     @property
     @abc.abstractmethod
-    def user_config(self) -> MarimoConfig:
+    def marimo_config(self) -> MarimoConfig:
+        """
+        Get the marimo configuration.
+        This is a merged configuration from the user config and project config.
+        """
         pass
 
     @property
diff --git a/marimo/_server/api/deps.py b/marimo/_server/api/deps.py
index 7a8d2621604..4bee745744d 100644
--- a/marimo/_server/api/deps.py
+++ b/marimo/_server/api/deps.py
@@ -4,7 +4,7 @@
 from typing import TYPE_CHECKING, Any, Optional, Union, cast
 
 from marimo import _loggers as loggers
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import MarimoConfigManager
 from marimo._server.ids import SessionId
 from marimo._server.model import SessionMode
 from marimo._server.sessions import Session, SessionManager
@@ -78,8 +78,9 @@ def server(self) -> Server:
         return server
 
     @property
-    def config_manager(self) -> UserConfigManager:
-        cm: UserConfigManager = self.state.config_manager
+    def config_manager(self) -> MarimoConfigManager:
+        cm = self.state.config_manager
+        assert isinstance(cm, MarimoConfigManager)
         return cm
 
     @property
diff --git a/marimo/_server/api/endpoints/assets.py b/marimo/_server/api/endpoints/assets.py
index 65296f4d348..56b2d700131 100644
--- a/marimo/_server/api/endpoints/assets.py
+++ b/marimo/_server/api/endpoints/assets.py
@@ -12,7 +12,7 @@
 from starlette.staticfiles import StaticFiles
 
 from marimo import _loggers
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import get_default_config_manager
 from marimo._runtime.virtual_file import EMPTY_VIRTUAL_FILE, read_virtual_file
 from marimo._server.api.deps import AppState
 from marimo._server.router import APIRouter
@@ -33,7 +33,11 @@
 # Root directory for static assets
 root = os.path.realpath(str(import_files("marimo").joinpath("_static")))
 
-config = UserConfigManager().get_config().get("server", {})
+config = (
+    get_default_config_manager(current_path=None)
+    .get_config()
+    .get("server", {})
+)
 
 router.mount(
     "/assets",
@@ -51,7 +55,6 @@
 @requires("read", redirect="auth:login_page")
 async def index(request: Request) -> HTMLResponse:
     app_state = AppState(request)
-    user_config = app_state.config_manager.get_config()
     index_html = os.path.join(root, "index.html")
 
     file_key = (
@@ -68,7 +71,8 @@ async def index(request: Request) -> HTMLResponse:
         html = home_page_template(
             html=html,
             base_url=app_state.base_url,
-            user_config=user_config,
+            user_config=app_state.config_manager.get_user_config(),
+            config_overrides=app_state.config_manager.get_config_overrides(),
             server_token=app_state.skew_protection_token,
         )
     else:
@@ -80,7 +84,8 @@ async def index(request: Request) -> HTMLResponse:
         html = notebook_page_template(
             html=html,
             base_url=app_state.base_url,
-            user_config=user_config,
+            user_config=app_state.config_manager.get_user_config(),
+            config_overrides=app_state.config_manager.get_config_overrides(),
             server_token=app_state.skew_protection_token,
             app_config=app_config,
             filename=app_manager.filename,
diff --git a/marimo/_server/asgi.py b/marimo/_server/asgi.py
index 1a35b07d48c..59307b8c519 100644
--- a/marimo/_server/asgi.py
+++ b/marimo/_server/asgi.py
@@ -322,7 +322,7 @@ async def root():
     from starlette.responses import RedirectResponse
 
     import marimo._server.api.lifespans as lifespans
-    from marimo._config.manager import UserConfigManager
+    from marimo._config.manager import get_default_config_manager
     from marimo._server.file_router import AppFileRouter
     from marimo._server.main import create_starlette_app
     from marimo._server.model import SessionMode
@@ -331,7 +331,7 @@ async def root():
     from marimo._server.utils import initialize_asyncio
     from marimo._utils.marimo_path import MarimoPath
 
-    user_config_mgr = UserConfigManager()
+    config_reader = get_default_config_manager(current_path=None)
     base_app = Starlette()
 
     # Default to an empty token
@@ -371,7 +371,7 @@ def _create_app_for_file(base_url: str, file_path: str) -> ASGIApp:
                 # Currently we only support run mode,
                 # which doesn't require an LSP server
                 lsp_server=NoopLspServer(),
-                user_config_manager=user_config_mgr,
+                user_config_manager=config_reader,
                 # We don't pass any CLI args for now
                 # since we don't want to read arbitrary args and apply them
                 # to each application
@@ -392,7 +392,7 @@ def _create_app_for_file(base_url: str, file_path: str) -> ASGIApp:
             )
             app.state.session_manager = session_manager
             app.state.base_url = base_url
-            app.state.config_manager = user_config_mgr
+            app.state.config_manager = config_reader
             return app
 
         def build(self) -> "ASGIApp":
diff --git a/marimo/_server/export/__init__.py b/marimo/_server/export/__init__.py
index 2e47e9ac0b7..4a05fdca14e 100644
--- a/marimo/_server/export/__init__.py
+++ b/marimo/_server/export/__init__.py
@@ -5,8 +5,7 @@
 from typing import Callable, Literal
 
 from marimo._config.manager import (
-    UserConfigManager,
-    UserConfigManagerWithOverride,
+    get_default_config_manager,
 )
 from marimo._messaging.ops import MessageOperation
 from marimo._messaging.types import KernelMessage
@@ -85,7 +84,7 @@ async def run_app_then_export_as_html(
     assert file_key is not None
     file_manager = file_router.get_file_manager(file_key)
 
-    config = UserConfigManager()
+    config = get_default_config_manager(current_path=file_manager.path)
     session_view = await run_app_until_completion(file_manager, cli_args)
     # Export the session as HTML
     html, filename = Exporter().export_as_html(
@@ -147,15 +146,16 @@ def write_operation(self, op: MessageOperation) -> None:
         def connection_state(self) -> ConnectionState:
             return ConnectionState.OPEN
 
-    config_manager = UserConfigManagerWithOverride(
-        UserConfigManager(),
+    config_manager = get_default_config_manager(
+        current_path=file_manager.path
+    ).with_overrides(
         {
             "runtime": {
                 "on_cell_change": "autorun",
                 "auto_instantiate": True,
                 "auto_reload": "off",
             }
-        },
+        }
     )
 
     # Create a session
diff --git a/marimo/_server/export/exporter.py b/marimo/_server/export/exporter.py
index 77c5391a509..e1615d05480 100644
--- a/marimo/_server/export/exporter.py
+++ b/marimo/_server/export/exporter.py
@@ -12,8 +12,8 @@
 from marimo._config.config import (
     DEFAULT_CONFIG,
     DisplayConfig,
-    _deep_copy,
 )
+from marimo._config.utils import deep_copy
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._messaging.cell_output import CellChannel, CellOutput
 from marimo._messaging.mimetypes import KnownMimeType
@@ -76,7 +76,7 @@ def export_as_html(
         # since we use:
         # - display.theme
         # - display.cell_output
-        config = _deep_copy(DEFAULT_CONFIG)
+        config = deep_copy(DEFAULT_CONFIG)
         config["display"] = display_config
 
         # code and console outputs are grouped together, but
@@ -98,6 +98,7 @@ def export_as_html(
         html = static_notebook_template(
             html=index_html,
             user_config=config,
+            config_overrides={},
             server_token=SkewProtectionToken("static"),
             app_config=file_manager.app.config,
             filepath=file_manager.filename,
diff --git a/marimo/_server/sessions.py b/marimo/_server/sessions.py
index 945ad625532..dc1f11cb337 100644
--- a/marimo/_server/sessions.py
+++ b/marimo/_server/sessions.py
@@ -33,7 +33,7 @@
 from marimo import _loggers
 from marimo._ast.cell import CellConfig, CellId_t
 from marimo._cli.print import red
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import MarimoConfigReader
 from marimo._config.settings import GLOBAL_SETTINGS
 from marimo._messaging.ops import (
     Alert,
@@ -119,9 +119,9 @@ def __init__(self, use_multiprocessing: bool):
             if context is not None
             else queue.Queue(maxsize=1)
         )
-        self.stream_queue: (
-            "queue.Queue[Union[KernelMessage , None]]" | None
-        ) = None
+        self.stream_queue: Optional[
+            queue.Queue[Union[KernelMessage, None]]
+        ] = None
         if not use_multiprocessing:
             self.stream_queue = queue.Queue()
 
@@ -162,7 +162,7 @@ def __init__(
         mode: SessionMode,
         configs: dict[CellId_t, CellConfig],
         app_metadata: AppMetadata,
-        user_config_manager: UserConfigManager,
+        user_config_manager: MarimoConfigReader,
         virtual_files_supported: bool,
         redirect_console_to_browser: bool = False,
     ) -> None:
@@ -413,7 +413,7 @@ def create(
         mode: SessionMode,
         app_metadata: AppMetadata,
         app_file_manager: AppFileManager,
-        user_config_manager: UserConfigManager,
+        user_config_manager: MarimoConfigReader,
         virtual_files_supported: bool,
         redirect_console_to_browser: bool,
     ) -> Session:
@@ -657,7 +657,7 @@ def __init__(
         quiet: bool,
         include_code: bool,
         lsp_server: LspServer,
-        user_config_manager: UserConfigManager,
+        user_config_manager: MarimoConfigReader,
         cli_args: SerializedCLIArgs,
         auth_token: Optional[AuthToken],
         redirect_console_to_browser: bool = False,
diff --git a/marimo/_server/start.py b/marimo/_server/start.py
index 1a9d47074d2..d2eee2fa85c 100644
--- a/marimo/_server/start.py
+++ b/marimo/_server/start.py
@@ -8,7 +8,7 @@
 import uvicorn
 
 import marimo._server.api.lifespans as lifespans
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import get_default_config_manager
 from marimo._runtime.requests import SerializedCLIArgs
 from marimo._server.file_router import AppFileRouter
 from marimo._server.main import create_starlette_app
@@ -90,7 +90,17 @@ def start(
     # if the user specifies a port, we don't try to find a free one
     port = port or find_free_port(DEFAULT_PORT)
     lsp_port = find_free_port(DEFAULT_PORT + 200)  # Add 200 to avoid conflicts
-    user_config_mgr = UserConfigManager()
+
+    # This is the path that will be used to read the project configuration
+    start_path: Optional[str] = None
+    if (single_file := file_router.maybe_get_single_file()) is not None:
+        start_path = single_file.path
+    elif (directory := file_router.directory) is not None:
+        start_path = directory
+    else:
+        start_path = os.getcwd()
+
+    config_reader = get_default_config_manager(current_path=start_path)
 
     session_manager = SessionManager(
         file_router=file_router,
@@ -99,7 +109,7 @@ def start(
         quiet=quiet,
         include_code=include_code,
         lsp_server=LspServer(lsp_port),
-        user_config_manager=user_config_mgr,
+        user_config_manager=config_reader,
         cli_args=cli_args,
         auth_token=auth_token,
         redirect_console_to_browser=redirect_console_to_browser,
@@ -134,7 +144,7 @@ def start(
     app.state.watch = watch
     app.state.session_manager = session_manager
     app.state.base_url = base_url
-    app.state.config_manager = user_config_mgr
+    app.state.config_manager = config_reader
 
     # Resource initialization
     # Increase the limit on open file descriptors to prevent resource
diff --git a/marimo/_server/templates/templates.py b/marimo/_server/templates/templates.py
index cc7951913e0..0c7b646956d 100644
--- a/marimo/_server/templates/templates.py
+++ b/marimo/_server/templates/templates.py
@@ -10,7 +10,7 @@
 from marimo import __version__
 from marimo._ast.app import _AppConfig
 from marimo._ast.cell import CellConfig, CellId_t
-from marimo._config.config import MarimoConfig
+from marimo._config.config import MarimoConfig, PartialMarimoConfig
 from marimo._messaging.cell_output import CellOutput
 from marimo._output.utils import uri_encode_component
 from marimo._server.api.utils import parse_title
@@ -23,10 +23,12 @@ def home_page_template(
     html: str,
     base_url: str,
     user_config: MarimoConfig,
+    config_overrides: PartialMarimoConfig,
     server_token: SkewProtectionToken,
 ) -> str:
     html = html.replace("{{ base_url }}", base_url)
     html = html.replace("{{ user_config }}", json.dumps(user_config))
+    html = html.replace("{{ config_overrides }}", json.dumps(config_overrides))
     html = html.replace("{{ server_token }}", str(server_token))
     html = html.replace("{{ version }}", __version__)
 
@@ -42,6 +44,7 @@ def notebook_page_template(
     html: str,
     base_url: str,
     user_config: MarimoConfig,
+    config_overrides: PartialMarimoConfig,
     server_token: SkewProtectionToken,
     app_config: _AppConfig,
     filename: Optional[str],
@@ -49,6 +52,7 @@ def notebook_page_template(
 ) -> str:
     html = html.replace("{{ base_url }}", base_url)
     html = html.replace("{{ user_config }}", json.dumps(user_config))
+    html = html.replace("{{ config_overrides }}", json.dumps(config_overrides))
     html = html.replace("{{ server_token }}", str(server_token))
     html = html.replace("{{ version }}", __version__)
 
@@ -89,6 +93,7 @@ def notebook_page_template(
 def static_notebook_template(
     html: str,
     user_config: MarimoConfig,
+    config_overrides: PartialMarimoConfig,
     server_token: SkewProtectionToken,
     app_config: _AppConfig,
     filepath: Optional[str],
@@ -114,6 +119,7 @@ def static_notebook_template(
     html = html.replace(
         "{{ user_config }}", json.dumps(user_config, sort_keys=True)
     )
+    html = html.replace("{{ config_overrides }}", json.dumps(config_overrides))
     html = html.replace("{{ server_token }}", str(server_token))
     html = html.replace("{{ version }}", __version__)
 
diff --git a/marimo/_utils/marimo_path.py b/marimo/_utils/marimo_path.py
index 68d2d2ab646..64c2de78f31 100644
--- a/marimo/_utils/marimo_path.py
+++ b/marimo/_utils/marimo_path.py
@@ -2,6 +2,7 @@
 from __future__ import annotations
 
 from pathlib import Path
+from typing import Union
 
 
 class MarimoPath:
@@ -10,7 +11,7 @@ class MarimoPath:
     And reduce API surface area of pathlib.Path.
     """
 
-    def __init__(self, path: str | Path, strict: bool = False) -> None:
+    def __init__(self, path: Union[str, Path], strict: bool = False) -> None:
         self.path: Path = Path(path)
         # Do this on initialization to avoid issues with changing directories
         self.cwd = Path.cwd()
@@ -27,7 +28,7 @@ def validate(self) -> None:
             )
 
     @staticmethod
-    def is_valid_path(path: str | Path) -> bool:
+    def is_valid_path(path: Union[str, Path]) -> bool:
         try:
             MarimoPath(path)
             return True
diff --git a/pyproject.toml b/pyproject.toml
index f5cf641333b..f8a237b4791 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -424,3 +424,18 @@ extend-exclude = [
 
 [tool.black]
 line-length = 79
+
+[tool.marimo.ai]
+rules = "- prefer polars over pandas\n- make charts using altair"
+
+[tool.marimo.save]
+autosave_delay = 1000
+autosave = "after_delay"
+format_on_save = true
+
+[tool.marimo.experimental]
+multi_column = true
+chat_sidebar = true
+
+[tool.marimo.display]
+dataframes = "rich"
\ No newline at end of file
diff --git a/tests/_cli/test_cli_config.py b/tests/_cli/test_cli_config.py
index ae8a18a43ed..a313514af88 100644
--- a/tests/_cli/test_cli_config.py
+++ b/tests/_cli/test_cli_config.py
@@ -13,3 +13,11 @@ def test_config_show() -> None:
     output = p.stdout.decode()
     assert "User config from" in output
     assert "[formatting]" in output
+
+
+def test_config_describe() -> None:
+    p = subprocess.run(
+        ["marimo", "config", "describe"],
+        capture_output=True,
+    )
+    assert p.returncode == 0, p.stderr.decode()
diff --git a/tests/_config/test_config.py b/tests/_config/test_config.py
index 4d421b74857..c5a1fd953c1 100644
--- a/tests/_config/test_config.py
+++ b/tests/_config/test_config.py
@@ -1,26 +1,18 @@
 # Copyright 2024 Marimo. All rights reserved.
 from __future__ import annotations
 
-import os
-import tempfile
-from contextlib import contextmanager
-from pathlib import Path
-from typing import List, Optional
-from unittest import mock
+from typing import Union
 
 from marimo._config.config import (
     DEFAULT_CONFIG,
     MarimoConfig,
     PartialMarimoConfig,
-    mask_secrets,
     merge_config,
     merge_default_config,
-    remove_secret_placeholders,
 )
-from marimo._config.utils import get_config_path, get_or_create_config_path
 
 
-def assert_config(override: MarimoConfig | PartialMarimoConfig) -> None:
+def assert_config(override: Union[MarimoConfig, PartialMarimoConfig]) -> None:
     user_config = merge_default_config(override)
     assert user_config == {**DEFAULT_CONFIG, **override}
 
@@ -131,156 +123,3 @@ def test_merge_config_with_keymap_overrides() -> None:
 
     assert new_config["keymap"]["preset"] == "vim"
     assert new_config["keymap"]["overrides"] == {}
-
-
-def test_mask_secrets() -> None:
-    config = PartialMarimoConfig(
-        ai={
-            "open_ai": {"api_key": "super_secret"},
-            "anthropic": {"api_key": "anthropic_secret"},
-            "google": {"api_key": "google_secret"},
-        }
-    )
-    assert config["ai"]["open_ai"]["api_key"] == "super_secret"
-    assert config["ai"]["anthropic"]["api_key"] == "anthropic_secret"
-    assert config["ai"]["google"]["api_key"] == "google_secret"
-
-    new_config = mask_secrets(config)
-    assert new_config["ai"]["open_ai"]["api_key"] == "********"
-    assert new_config["ai"]["anthropic"]["api_key"] == "********"
-    assert new_config["ai"]["google"]["api_key"] == "********"
-
-    # Ensure the original config is not modified
-    assert config["ai"]["open_ai"]["api_key"] == "super_secret"
-    assert config["ai"]["anthropic"]["api_key"] == "anthropic_secret"
-    assert config["ai"]["google"]["api_key"] == "google_secret"
-
-
-def test_mask_secrets_empty() -> None:
-    config = PartialMarimoConfig(
-        ai={
-            "open_ai": {"model": "davinci"},
-            "google": {},
-            "anthropic": {},
-        }
-    )
-    assert config["ai"]["open_ai"]["model"] == "davinci"
-
-    new_config = mask_secrets(config)
-    assert new_config["ai"]["open_ai"]["model"] == "davinci"
-    # Not added until the key is present
-    assert "api_key" not in new_config["ai"]["open_ai"]
-    assert "api_key" not in new_config["ai"]["google"]
-    assert "api_key" not in new_config["ai"]["anthropic"]
-    # Ensure the original config is not modified
-    assert config["ai"]["open_ai"]["model"] == "davinci"
-
-    # Not added when key is ""
-    config["ai"]["open_ai"]["api_key"] = ""
-    config["ai"]["google"]["api_key"] = ""
-    config["ai"]["anthropic"]["api_key"] = ""
-    new_config = mask_secrets(config)
-    assert new_config["ai"]["open_ai"]["api_key"] == ""
-    assert new_config["ai"]["google"]["api_key"] == ""
-    assert new_config["ai"]["anthropic"]["api_key"] == ""
-    # Ensure the original config is not modified
-    assert config["ai"]["open_ai"]["api_key"] == ""
-    assert config["ai"]["google"]["api_key"] == ""
-    assert config["ai"]["anthropic"]["api_key"] == ""
-
-
-def test_remove_secret_placeholders() -> None:
-    config = PartialMarimoConfig(
-        ai={
-            "open_ai": {"api_key": "********"},
-            "google": {"api_key": "********"},
-            "anthropic": {"api_key": "********"},
-        }
-    )
-    assert config["ai"]["open_ai"]["api_key"] == "********"
-    assert config["ai"]["google"]["api_key"] == "********"
-    assert config["ai"]["anthropic"]["api_key"] == "********"
-    new_config = remove_secret_placeholders(config)
-    assert "api_key" not in new_config["ai"]["open_ai"]
-    assert "api_key" not in new_config["ai"]["google"]
-    assert "api_key" not in new_config["ai"]["anthropic"]
-    # Ensure the original config is not modified
-    assert config["ai"]["open_ai"]["api_key"] == "********"
-    assert config["ai"]["google"]["api_key"] == "********"
-    assert config["ai"]["anthropic"]["api_key"] == "********"
-
-
-@contextmanager
-def _mock_file_exists(
-    exists: Optional[str | List[str]] = None,
-    doesnt_exist: Optional[str | List[str]] = None,
-):
-    if isinstance(exists, str):
-        exists = [exists]
-    if isinstance(doesnt_exist, str):
-        doesnt_exist = [doesnt_exist]
-
-    isfile = os.path.isfile
-
-    def mock_exists(check_path):
-        if (exists is not None) and (check_path in exists):
-            return True
-        if (doesnt_exist is not None) and (check_path in doesnt_exist):
-            return False
-        return isfile(check_path)
-
-    with mock.patch(
-        "marimo._config.utils.os.path.isfile",
-        side_effect=mock_exists,
-    ):
-        yield
-
-
-def test_get_config_path():
-    xdg_config_path = str(Path("~/.config/marimo/marimo.toml").expanduser())
-    home_config_path = str(Path("~/.marimo.toml").expanduser())
-
-    # If neither config exists, return None
-    with _mock_file_exists(doesnt_exist=[xdg_config_path, home_config_path]):
-        found_config_path = get_config_path()
-        assert found_config_path is None
-
-    # If only XDG path exists, use XDG path
-    with _mock_file_exists(
-        exists=xdg_config_path, doesnt_exist=home_config_path
-    ):
-        found_config_path = get_config_path()
-        assert found_config_path == xdg_config_path
-
-    # If both config paths exist, home config takes precedence
-    with _mock_file_exists(exists=[xdg_config_path, home_config_path]):
-        found_config_path = get_config_path()
-        assert found_config_path == home_config_path
-
-
-def test_get_or_create_config_path():
-    with tempfile.TemporaryDirectory() as temp_dir:
-        # Use temp dir to avoid creating stray xdg config.
-        # Still creates home one though, unfortunately.
-        os.environ["XDG_CONFIG_HOME"] = temp_dir
-        xdg_config_path = str(Path(temp_dir) / "marimo/marimo.toml")
-        home_config_path = str(Path("~/.marimo.toml").expanduser())
-
-        # If neither config exists, XDG config should be created and used
-        with _mock_file_exists(
-            doesnt_exist=[xdg_config_path, home_config_path]
-        ):
-            found_config_path = get_or_create_config_path()
-            assert found_config_path == xdg_config_path
-
-        # If only XDG path exists, use XDG path
-        with _mock_file_exists(
-            exists=xdg_config_path, doesnt_exist=home_config_path
-        ):
-            found_config_path = get_or_create_config_path()
-            assert found_config_path == xdg_config_path
-
-        # If both config paths exist, home config takes precedence
-        with _mock_file_exists(exists=[xdg_config_path, home_config_path]):
-            found_config_path = get_or_create_config_path()
-            assert found_config_path == home_config_path
diff --git a/tests/_config/test_config_utils.py b/tests/_config/test_config_utils.py
new file mode 100644
index 00000000000..ec016959364
--- /dev/null
+++ b/tests/_config/test_config_utils.py
@@ -0,0 +1,90 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+import os
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+from typing import List, Optional
+from unittest import mock
+
+from marimo._config.utils import (
+    get_or_create_user_config_path,
+    get_user_config_path,
+)
+
+
+@contextmanager
+def _mock_file_exists(
+    exists: Optional[str | List[str]] = None,
+    doesnt_exist: Optional[str | List[str]] = None,
+):
+    if isinstance(exists, str):
+        exists = [exists]
+    if isinstance(doesnt_exist, str):
+        doesnt_exist = [doesnt_exist]
+
+    isfile = os.path.isfile
+
+    def mock_exists(check_path: str) -> bool:
+        if (exists is not None) and (check_path in exists):
+            return True
+        if (doesnt_exist is not None) and (check_path in doesnt_exist):
+            return False
+        return isfile(check_path)
+
+    with mock.patch(
+        "marimo._config.utils.os.path.isfile",
+        side_effect=mock_exists,
+    ):
+        yield
+
+
+def test_get_config_path():
+    xdg_config_path = str(Path("~/.config/marimo/marimo.toml").expanduser())
+    home_config_path = str(Path("~/.marimo.toml").expanduser())
+
+    # If neither config exists, return None
+    with _mock_file_exists(doesnt_exist=[xdg_config_path, home_config_path]):
+        found_config_path = get_user_config_path()
+        assert found_config_path is None
+
+    # If only XDG path exists, use XDG path
+    with _mock_file_exists(
+        exists=xdg_config_path, doesnt_exist=home_config_path
+    ):
+        found_config_path = get_user_config_path()
+        assert found_config_path == xdg_config_path
+
+    # If both config paths exist, home config takes precedence
+    with _mock_file_exists(exists=[xdg_config_path, home_config_path]):
+        found_config_path = get_user_config_path()
+        assert found_config_path == home_config_path
+
+
+def test_get_or_create_config_path():
+    with tempfile.TemporaryDirectory() as temp_dir:
+        # Use temp dir to avoid creating stray xdg config.
+        # Still creates home one though, unfortunately.
+        os.environ["XDG_CONFIG_HOME"] = temp_dir
+        xdg_config_path = str(Path(temp_dir) / "marimo/marimo.toml")
+        home_config_path = str(Path("~/.marimo.toml").expanduser())
+
+        # If neither config exists, XDG config should be created and used
+        with _mock_file_exists(
+            doesnt_exist=[xdg_config_path, home_config_path]
+        ):
+            found_config_path = get_or_create_user_config_path()
+            assert found_config_path == xdg_config_path
+
+        # If only XDG path exists, use XDG path
+        with _mock_file_exists(
+            exists=xdg_config_path, doesnt_exist=home_config_path
+        ):
+            found_config_path = get_or_create_user_config_path()
+            assert found_config_path == xdg_config_path
+
+        # If both config paths exist, home config takes precedence
+        with _mock_file_exists(exists=[xdg_config_path, home_config_path]):
+            found_config_path = get_or_create_user_config_path()
+            assert found_config_path == home_config_path
diff --git a/tests/_config/test_manager.py b/tests/_config/test_manager.py
index 3daa5e371d5..d5955f37699 100644
--- a/tests/_config/test_manager.py
+++ b/tests/_config/test_manager.py
@@ -1,22 +1,22 @@
 from __future__ import annotations
 
-import unittest
 from functools import wraps
 from typing import Any, Callable, TypeVar
 from unittest.mock import patch
 
 from marimo._config.config import PartialMarimoConfig, merge_default_config
 from marimo._config.manager import (
+    MarimoConfigManager,
+    MarimoConfigReaderWithOverrides,
     UserConfigManager,
-    UserConfigManagerWithOverride,
+    get_default_config_manager,
 )
-from marimo._config.utils import load_config
 
 F = TypeVar("F", bound=Callable[..., Any])
 
 
 def restore_config(f: F) -> F:
-    config = load_config()
+    config = UserConfigManager().get_config()
 
     @wraps(f)
     def wrapper(*args: Any, **kwargs: Any) -> Any:
@@ -28,94 +28,104 @@ def wrapper(*args: Any, **kwargs: Any) -> Any:
     return wrapper  # type: ignore
 
 
-class TestUserConfigManager(unittest.TestCase):
-    @restore_config
-    @patch("tomlkit.dump")
-    @patch("marimo._config.manager.load_config")
-    def test_save_config(self, mock_load: Any, mock_dump: Any) -> None:
-        mock_config = merge_default_config(PartialMarimoConfig())
-        mock_load.return_value = mock_config
-        manager = UserConfigManager()
-
-        result = manager.save_config(mock_config)
-
-        mock_load.assert_called_once()
-        assert result == manager._config
-
-        assert mock_dump.mock_calls[0][1][0] == result
-
-    @restore_config
-    @patch("tomlkit.dump")
-    @patch("marimo._config.manager.load_config")
-    def test_can_save_secrets(self, mock_load: Any, mock_dump: Any) -> None:
-        mock_config = merge_default_config(PartialMarimoConfig())
-        mock_load.return_value = mock_config
-        manager = UserConfigManager()
-
-        manager.save_config(
-            merge_default_config(
-                PartialMarimoConfig(
-                    ai={"open_ai": {"api_key": "super_secret"}}
-                )
-            )
-        )
+@restore_config
+@patch("tomlkit.dump")
+def test_save_config(mock_dump: Any) -> None:
+    mock_config = merge_default_config(PartialMarimoConfig())
+    manager = UserConfigManager()
+    manager._load_config = lambda: mock_config
 
-        assert (
-            mock_dump.mock_calls[0][1][0]["ai"]["open_ai"]["api_key"]
-            == "super_secret"
-        )
+    result = manager.save_config(mock_config)
 
-        # Do not overwrite secrets
-        manager.save_config(
-            merge_default_config(
-                PartialMarimoConfig(ai={"open_ai": {"api_key": "********"}})
-            )
-        )
-        assert (
-            mock_dump.mock_calls[1][1][0]["ai"]["open_ai"]["api_key"]
-            == "super_secret"
-        )
+    assert result == mock_config
+
+    assert mock_dump.mock_calls[0][1][0] == mock_config
 
-    @restore_config
-    @patch("marimo._config.manager.load_config")
-    def test_can_read_secrets(self, mock_load: Any) -> None:
-        mock_config = merge_default_config(
+
+@restore_config
+@patch("tomlkit.dump")
+def test_can_save_secrets(mock_dump: Any) -> None:
+    mock_config = merge_default_config(PartialMarimoConfig())
+    manager = UserConfigManager()
+    manager._load_config = lambda: mock_config
+
+    new_config = manager.save_config(
+        merge_default_config(
             PartialMarimoConfig(ai={"open_ai": {"api_key": "super_secret"}})
         )
-        mock_load.return_value = mock_config
-        manager = UserConfigManager()
-
-        assert manager.get_config()["ai"]["open_ai"]["api_key"] == "********"
-        assert (
-            manager.get_config(hide_secrets=False)["ai"]["open_ai"]["api_key"]
-            == "super_secret"
+    )
+    manager._load_config = lambda: new_config
+
+    assert (
+        mock_dump.mock_calls[0][1][0]["ai"]["open_ai"]["api_key"]
+        == "super_secret"
+    )
+
+    # Do not overwrite secrets
+    manager.save_config(
+        merge_default_config(
+            PartialMarimoConfig(ai={"open_ai": {"api_key": "********"}})
         )
+    )
+    assert (
+        mock_dump.mock_calls[1][1][0]["ai"]["open_ai"]["api_key"]
+        == "super_secret"
+    )
+
 
-    @restore_config
-    @patch("marimo._config.manager.load_config")
-    def test_get_config(self, mock_load: Any) -> None:
-        mock_config = merge_default_config(PartialMarimoConfig())
-        mock_load.return_value = mock_config
-        manager = UserConfigManager()
-
-        result = manager.get_config()
-
-        mock_load.assert_called_once()
-        assert result == manager._config
-
-    @restore_config
-    @patch("marimo._config.manager.load_config")
-    def test_get_config_with_override(self, mock_load: Any) -> None:
-        mock_config = merge_default_config(PartialMarimoConfig())
-        mock_load.return_value = mock_config
-        manager = UserConfigManagerWithOverride(
-            UserConfigManager(),
+@restore_config
+def test_can_read_secrets() -> None:
+    manager = UserConfigManager()
+    mock_config = merge_default_config(
+        PartialMarimoConfig(ai={"open_ai": {"api_key": "super_secret"}})
+    )
+    manager._load_config = lambda: mock_config
+
+    assert manager.get_config()["ai"]["open_ai"]["api_key"] == "********"
+    assert (
+        manager.get_config(hide_secrets=False)["ai"]["open_ai"]["api_key"]
+        == "super_secret"
+    )
+
+
+@restore_config
+def test_get_config() -> None:
+    mock_config = merge_default_config(PartialMarimoConfig())
+    manager = UserConfigManager()
+    manager._load_config = lambda: mock_config
+
+    result = manager.get_config()
+
+    assert result == mock_config
+
+
+@restore_config
+def test_get_config_with_override() -> None:
+    mock_config = merge_default_config(PartialMarimoConfig())
+    user = UserConfigManager()
+    user._load_config = lambda: mock_config
+
+    manager = MarimoConfigManager(
+        user,
+        MarimoConfigReaderWithOverrides(
             {
                 "runtime": {
                     "on_cell_change": "autorun",
                     "auto_instantiate": True,
                     "auto_reload": "lazy",
                 }
-            },
-        )
-        assert manager.get_config()["runtime"]["auto_reload"] == "lazy"
+            }
+        ),
+    )
+    assert manager.get_config()["runtime"]["auto_reload"] == "lazy"
+
+    manager = get_default_config_manager(current_path=None).with_overrides(
+        {
+            "runtime": {
+                "on_cell_change": "autorun",
+                "auto_instantiate": True,
+                "auto_reload": "lazy",
+            }
+        }
+    )
+    assert manager.get_config()["runtime"]["auto_reload"] == "lazy"
diff --git a/tests/_config/test_reader.py b/tests/_config/test_reader.py
new file mode 100644
index 00000000000..a03276d1a35
--- /dev/null
+++ b/tests/_config/test_reader.py
@@ -0,0 +1,145 @@
+import os
+import tempfile
+from pathlib import Path
+from unittest.mock import mock_open, patch
+
+import pytest
+
+from marimo._config.reader import (
+    find_nearest_pyproject_toml,
+    read_marimo_config,
+    read_pyproject_config,
+    read_toml,
+)
+
+
+def test_read_toml():
+    toml_content = """
+    [section]
+    key = "value"
+    """
+    with patch("builtins.open", mock_open(read_data=toml_content)):
+        result = read_toml("dummy.toml")
+        assert result == {"section": {"key": "value"}}
+
+
+def test_read_marimo_config():
+    config_content = """
+    [formatting]
+    line_length = 79
+
+    [save]
+    autosave_delay = 1000
+    format_on_save = true
+    autosave = "after_delay"
+
+    [novalidate]
+    old_keys_are_ok = true
+    """
+    with patch("builtins.open", mock_open(read_data=config_content)):
+        result = read_marimo_config("marimo.toml")
+        assert result == {
+            "formatting": {"line_length": 79},
+            "save": {
+                "autosave_delay": 1000,
+                "format_on_save": True,
+                "autosave": "after_delay",
+            },
+            "novalidate": {"old_keys_are_ok": True},
+        }
+
+
+def test_read_pyproject_config_with_marimo_section():
+    pyproject_content = """
+    [tool.marimo]
+    formatting = {line_length = 79}
+
+    [tool.marimo.save]
+    autosave_delay = 1000
+    format_on_save = true
+    autosave = "after_delay"
+
+    [tool.marimo.novalidate]
+    old_keys_are_ok = true
+    """
+    with patch("builtins.open", mock_open(read_data=pyproject_content)):
+        with patch(
+            "marimo._config.reader.find_nearest_pyproject_toml"
+        ) as mock_find:
+            mock_find.return_value = Path("/some/path/pyproject.toml")
+            result = read_pyproject_config("/some/path")
+            assert result == {
+                "formatting": {"line_length": 79},
+                "save": {
+                    "autosave_delay": 1000,
+                    "format_on_save": True,
+                    "autosave": "after_delay",
+                },
+                "novalidate": {"old_keys_are_ok": True},
+            }
+
+
+def test_read_pyproject_config_without_marimo_section():
+    pyproject_content = """
+    [tool.idk]
+    name = "foo"
+    """
+    with patch("builtins.open", mock_open(read_data=pyproject_content)):
+        with patch(
+            "marimo._config.reader.find_nearest_pyproject_toml"
+        ) as mock_find:
+            mock_find.return_value = Path("/some/path/pyproject.toml")
+            result = read_pyproject_config("/some/path")
+            assert result is None
+
+
+def test_read_pyproject_config_invalid_marimo_section():
+    pyproject_content = """
+    [tool]
+    marimo = "invalid"
+    """
+    with patch("builtins.open", mock_open(read_data=pyproject_content)):
+        with patch(
+            "marimo._config.reader.find_nearest_pyproject_toml"
+        ) as mock_find:
+            mock_find.return_value = Path("/some/path/pyproject.toml")
+            result = read_pyproject_config("/some/path")
+            assert result is None
+
+
+def test_read_pyproject_config_no_file():
+    with tempfile.TemporaryDirectory() as temp_dir:
+        result = read_pyproject_config(temp_dir)
+        assert result is None
+
+
+def testfind_nearest_pyproject_toml():
+    with tempfile.TemporaryDirectory() as temp_dir:
+        parent_dir = os.path.join(temp_dir, "parent")
+        os.makedirs(parent_dir)
+        pyproject_path = os.path.join(parent_dir, "pyproject.toml")
+        with open(pyproject_path, "w") as f:
+            f.write("")
+
+        start_path = os.path.join(parent_dir, "child")
+        os.makedirs(start_path)
+        result = find_nearest_pyproject_toml(start_path)
+        assert result == Path(pyproject_path)
+
+
+def testfind_nearest_pyproject_toml_not_found():
+    with tempfile.TemporaryDirectory() as temp_dir:
+        result = find_nearest_pyproject_toml(temp_dir)
+        assert result is None
+
+
+def test_read_toml_invalid_content():
+    invalid_toml = """
+    [invalid
+    key = value
+    """
+    import tomlkit.exceptions
+
+    with patch("builtins.open", mock_open(read_data=invalid_toml)):
+        with pytest.raises(tomlkit.exceptions.UnexpectedCharError):
+            read_toml("dummy.toml")
diff --git a/tests/_config/test_secrets.py b/tests/_config/test_secrets.py
new file mode 100644
index 00000000000..9c98113594c
--- /dev/null
+++ b/tests/_config/test_secrets.py
@@ -0,0 +1,79 @@
+from marimo._config.config import PartialMarimoConfig
+from marimo._config.secrets import mask_secrets, remove_secret_placeholders
+
+
+def test_mask_secrets() -> None:
+    config = PartialMarimoConfig(
+        ai={
+            "open_ai": {"api_key": "super_secret"},
+            "anthropic": {"api_key": "anthropic_secret"},
+            "google": {"api_key": "google_secret"},
+        }
+    )
+    assert config["ai"]["open_ai"]["api_key"] == "super_secret"
+    assert config["ai"]["anthropic"]["api_key"] == "anthropic_secret"
+    assert config["ai"]["google"]["api_key"] == "google_secret"
+
+    new_config = mask_secrets(config)
+    assert new_config["ai"]["open_ai"]["api_key"] == "********"
+    assert new_config["ai"]["anthropic"]["api_key"] == "********"
+    assert new_config["ai"]["google"]["api_key"] == "********"
+
+    # Ensure the original config is not modified
+    assert config["ai"]["open_ai"]["api_key"] == "super_secret"
+    assert config["ai"]["anthropic"]["api_key"] == "anthropic_secret"
+    assert config["ai"]["google"]["api_key"] == "google_secret"
+
+
+def test_mask_secrets_empty() -> None:
+    config = PartialMarimoConfig(
+        ai={
+            "open_ai": {"model": "davinci"},
+            "google": {},
+            "anthropic": {},
+        }
+    )
+    assert config["ai"]["open_ai"]["model"] == "davinci"
+
+    new_config = mask_secrets(config)
+    assert new_config["ai"]["open_ai"]["model"] == "davinci"
+    # Not added until the key is present
+    assert "api_key" not in new_config["ai"]["open_ai"]
+    assert "api_key" not in new_config["ai"]["google"]
+    assert "api_key" not in new_config["ai"]["anthropic"]
+    # Ensure the original config is not modified
+    assert config["ai"]["open_ai"]["model"] == "davinci"
+
+    # Not added when key is ""
+    config["ai"]["open_ai"]["api_key"] = ""
+    config["ai"]["google"]["api_key"] = ""
+    config["ai"]["anthropic"]["api_key"] = ""
+    new_config = mask_secrets(config)
+    assert new_config["ai"]["open_ai"]["api_key"] == ""
+    assert new_config["ai"]["google"]["api_key"] == ""
+    assert new_config["ai"]["anthropic"]["api_key"] == ""
+    # Ensure the original config is not modified
+    assert config["ai"]["open_ai"]["api_key"] == ""
+    assert config["ai"]["google"]["api_key"] == ""
+    assert config["ai"]["anthropic"]["api_key"] == ""
+
+
+def test_remove_secret_placeholders() -> None:
+    config = PartialMarimoConfig(
+        ai={
+            "open_ai": {"api_key": "********"},
+            "google": {"api_key": "********"},
+            "anthropic": {"api_key": "********"},
+        }
+    )
+    assert config["ai"]["open_ai"]["api_key"] == "********"
+    assert config["ai"]["google"]["api_key"] == "********"
+    assert config["ai"]["anthropic"]["api_key"] == "********"
+    new_config = remove_secret_placeholders(config)
+    assert "api_key" not in new_config["ai"]["open_ai"]
+    assert "api_key" not in new_config["ai"]["google"]
+    assert "api_key" not in new_config["ai"]["anthropic"]
+    # Ensure the original config is not modified
+    assert config["ai"]["open_ai"]["api_key"] == "********"
+    assert config["ai"]["google"]["api_key"] == "********"
+    assert config["ai"]["anthropic"]["api_key"] == "********"
diff --git a/tests/_server/api/test_middleware.py b/tests/_server/api/test_middleware.py
index f3552369cc2..55f93e7a228 100644
--- a/tests/_server/api/test_middleware.py
+++ b/tests/_server/api/test_middleware.py
@@ -16,7 +16,7 @@
 from starlette.websockets import WebSocket, WebSocketDisconnect
 from uvicorn import Config, Server
 
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import MarimoConfigManager, UserConfigManager
 from marimo._server.api.middleware import (
     ProxyMiddleware,
     _AsyncHTTPClient,
@@ -38,7 +38,7 @@
 def test_base_url() -> None:
     app = create_starlette_app(base_url="/foo")
     app.state.session_manager = get_mock_session_manager()
-    app.state.config_manager = UserConfigManager()
+    app.state.config_manager = MarimoConfigManager(UserConfigManager())
     client = TestClient(app)
 
     # Mock out the server
@@ -113,7 +113,7 @@ def edit_app() -> Starlette:
     app = create_starlette_app(base_url="")
     app.state.session_manager = get_mock_session_manager()
     app.state.session_manager.mode = SessionMode.EDIT
-    app.state.config_manager = UserConfigManager()
+    app.state.config_manager = MarimoConfigManager(UserConfigManager())
     # Mock out the server
     uvicorn_server = uvicorn.Server(uvicorn.Config(app))
     uvicorn_server.servers = []
@@ -129,7 +129,7 @@ def read_app() -> Starlette:
     app = create_starlette_app(base_url="")
     app.state.session_manager = get_mock_session_manager()
     app.state.session_manager.mode = SessionMode.RUN
-    app.state.config_manager = UserConfigManager()
+    app.state.config_manager = MarimoConfigManager(UserConfigManager())
     # Mock out the server
     uvicorn_server = uvicorn.Server(uvicorn.Config(app))
     uvicorn_server.servers = []
@@ -151,7 +151,7 @@ def no_auth_edit_app() -> Starlette:
     app.state.session_manager = get_mock_session_manager()
     app.state.session_manager.mode = SessionMode.EDIT
     app.state.session_manager.auth_token = AuthToken("")  # no auth
-    app.state.config_manager = UserConfigManager()
+    app.state.config_manager = MarimoConfigManager(UserConfigManager())
     # Mock out the server
     uvicorn_server = uvicorn.Server(uvicorn.Config(app))
     uvicorn_server.servers = []
@@ -168,7 +168,7 @@ def no_auth_read_app() -> Starlette:
     app.state.session_manager = get_mock_session_manager()
     app.state.session_manager.mode = SessionMode.RUN
     app.state.session_manager.auth_token = AuthToken("")  # no auth
-    app.state.config_manager = UserConfigManager()
+    app.state.config_manager = MarimoConfigManager(UserConfigManager())
     # Mock out the server
     uvicorn_server = uvicorn.Server(uvicorn.Config(app))
     uvicorn_server.servers = []
diff --git a/tests/_server/conftest.py b/tests/_server/conftest.py
index ec0fd5f70db..f0855e82e36 100644
--- a/tests/_server/conftest.py
+++ b/tests/_server/conftest.py
@@ -10,7 +10,7 @@
 import uvicorn
 from starlette.testclient import TestClient
 
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import MarimoConfigManager, UserConfigManager
 from marimo._config.utils import CONFIG_FILENAME
 from marimo._server.main import create_starlette_app
 from marimo._server.sessions import SessionManager
@@ -33,20 +33,29 @@ def client_with_lifespans() -> Generator[TestClient, None, None]:
 
 
 @pytest.fixture
-def user_config_manager() -> UserConfigManager:
+def user_config_manager() -> Iterator[UserConfigManager]:
+    tmp = TemporaryDirectory()
+    config_path = os.path.join(tmp.name, CONFIG_FILENAME)
+    with open(config_path, "w") as f:
+        f.write("")
+
     class TestUserConfigManager(UserConfigManager):
-        def _get_config_path(self) -> str:
-            tmp = TemporaryDirectory()
-            return os.path.join(tmp.name, CONFIG_FILENAME)
+        def __init__(self) -> None:
+            super().__init__()
+
+        def get_config_path(self) -> str:
+            return config_path
+
+    yield TestUserConfigManager()
 
-    return TestUserConfigManager()
+    tmp.cleanup()
 
 
 @pytest.fixture
 def client(user_config_manager: UserConfigManager) -> Iterator[TestClient]:
     main = sys.modules["__main__"]
     app.state.session_manager = get_mock_session_manager()
-    app.state.config_manager = user_config_manager
+    app.state.config_manager = MarimoConfigManager(user_config_manager)
     client = TestClient(app)
 
     # Mock out the server
diff --git a/tests/_server/mocks.py b/tests/_server/mocks.py
index a1af9bdedc4..2b9ec878000 100644
--- a/tests/_server/mocks.py
+++ b/tests/_server/mocks.py
@@ -5,7 +5,7 @@
 import tempfile
 from typing import TYPE_CHECKING, Any, Callable, Optional, cast
 
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import get_default_config_manager
 from marimo._server.file_router import AppFileRouter
 from marimo._server.model import SessionMode
 from marimo._server.sessions import NoopLspServer, SessionManager
@@ -53,7 +53,7 @@ def __():
         quiet=False,
         include_code=True,
         lsp_server=lsp_server,
-        user_config_manager=UserConfigManager(),
+        user_config_manager=get_default_config_manager(current_path=None),
         cli_args={},
         auth_token=AuthToken("fake-token"),
     )
diff --git a/tests/_server/templates/data/index.html b/tests/_server/templates/data/index.html
index cfdff9ba2f3..3047ac8a1dd 100644
--- a/tests/_server/templates/data/index.html
+++ b/tests/_server/templates/data/index.html
@@ -24,7 +24,7 @@
     <script data-marimo="true">
       function __resizeIframe(obj) {
         var scrollbarHeight = 20; // Max between windows, mac, and linux
-        
+
         function setHeight() {
           var element = obj.contentWindow.document.documentElement;
           // If there is no vertical scrollbar, we don't need to resize the iframe
@@ -44,7 +44,7 @@
 
         // Resize the iframe to the height of the content and bottom scrollbar height
         setHeight();
-        
+
         // Resize the iframe when the content changes
         const resizeObserver = new ResizeObserver((entries) => {
           setHeight();
@@ -55,7 +55,7 @@
     <marimo-filename hidden>{{ filename }}</marimo-filename>
     <marimo-mode data-mode='{{ mode }}' hidden></marimo-mode>
     <marimo-version data-version='{{ version }}' hidden></marimo-version>
-    <marimo-user-config data-config='{{ user_config }}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{{ user_config }}' data-overrides='{{ config_overrides }}' hidden></marimo-user-config>
     <marimo-app-config data-config='{{ app_config }}' hidden></marimo-app-config>
     <marimo-server-token data-token='{{ server_token }}' hidden></marimo-server-token>
     <title>{{ title }}</title>
diff --git a/tests/_server/templates/snapshots/export1.txt b/tests/_server/templates/snapshots/export1.txt
index faf2a20115c..f95824c81a1 100644
--- a/tests/_server/templates/snapshots/export1.txt
+++ b/tests/_server/templates/snapshots/export1.txt
@@ -24,7 +24,7 @@
     <script data-marimo="true">
       function __resizeIframe(obj) {
         var scrollbarHeight = 20; // Max between windows, mac, and linux
-        
+
         function setHeight() {
           var element = obj.contentWindow.document.documentElement;
           // If there is no vertical scrollbar, we don't need to resize the iframe
@@ -44,7 +44,7 @@
 
         // Resize the iframe to the height of the content and bottom scrollbar height
         setHeight();
-        
+
         // Resize the iframe when the content changes
         const resizeObserver = new ResizeObserver((entries) => {
           setHeight();
@@ -55,7 +55,7 @@
     <marimo-filename hidden>notebook.py</marimo-filename>
     <marimo-mode data-mode='read' hidden></marimo-mode>
     <marimo-version data-version='0.0.0' hidden></marimo-version>
-    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' data-overrides='{"formatting": {"line_length": 100}}' hidden></marimo-user-config>
     <marimo-app-config data-config='{"width": "compact"}' hidden></marimo-app-config>
     <marimo-server-token data-token='token' hidden></marimo-server-token>
     <title>notebook</title>
diff --git a/tests/_server/templates/snapshots/export2.txt b/tests/_server/templates/snapshots/export2.txt
index 576adef7cde..aba72da4fbd 100644
--- a/tests/_server/templates/snapshots/export2.txt
+++ b/tests/_server/templates/snapshots/export2.txt
@@ -24,7 +24,7 @@
     <script data-marimo="true">
       function __resizeIframe(obj) {
         var scrollbarHeight = 20; // Max between windows, mac, and linux
-        
+
         function setHeight() {
           var element = obj.contentWindow.document.documentElement;
           // If there is no vertical scrollbar, we don't need to resize the iframe
@@ -44,7 +44,7 @@
 
         // Resize the iframe to the height of the content and bottom scrollbar height
         setHeight();
-        
+
         // Resize the iframe when the content changes
         const resizeObserver = new ResizeObserver((entries) => {
           setHeight();
@@ -55,7 +55,7 @@
     <marimo-filename hidden></marimo-filename>
     <marimo-mode data-mode='read' hidden></marimo-mode>
     <marimo-version data-version='0.0.0' hidden></marimo-version>
-    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' data-overrides='{"formatting": {"line_length": 100}}' hidden></marimo-user-config>
     <marimo-app-config data-config='{"width": "compact"}' hidden></marimo-app-config>
     <marimo-server-token data-token='token' hidden></marimo-server-token>
     <title>marimo</title>
diff --git a/tests/_server/templates/snapshots/export3.txt b/tests/_server/templates/snapshots/export3.txt
index 05eb973035f..529b84fae88 100644
--- a/tests/_server/templates/snapshots/export3.txt
+++ b/tests/_server/templates/snapshots/export3.txt
@@ -24,7 +24,7 @@
     <script data-marimo="true">
       function __resizeIframe(obj) {
         var scrollbarHeight = 20; // Max between windows, mac, and linux
-        
+
         function setHeight() {
           var element = obj.contentWindow.document.documentElement;
           // If there is no vertical scrollbar, we don't need to resize the iframe
@@ -44,7 +44,7 @@
 
         // Resize the iframe to the height of the content and bottom scrollbar height
         setHeight();
-        
+
         // Resize the iframe when the content changes
         const resizeObserver = new ResizeObserver((entries) => {
           setHeight();
@@ -55,7 +55,7 @@
     <marimo-filename hidden>notebook.py</marimo-filename>
     <marimo-mode data-mode='read' hidden></marimo-mode>
     <marimo-version data-version='0.0.0' hidden></marimo-version>
-    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' data-overrides='{"formatting": {"line_length": 100}}' hidden></marimo-user-config>
     <marimo-app-config data-config='{"width": "compact"}' hidden></marimo-app-config>
     <marimo-server-token data-token='token' hidden></marimo-server-token>
     <title>notebook</title>
diff --git a/tests/_server/templates/snapshots/export4.txt b/tests/_server/templates/snapshots/export4.txt
index 773548f8798..5e324135717 100644
--- a/tests/_server/templates/snapshots/export4.txt
+++ b/tests/_server/templates/snapshots/export4.txt
@@ -24,7 +24,7 @@
     <script data-marimo="true">
       function __resizeIframe(obj) {
         var scrollbarHeight = 20; // Max between windows, mac, and linux
-        
+
         function setHeight() {
           var element = obj.contentWindow.document.documentElement;
           // If there is no vertical scrollbar, we don't need to resize the iframe
@@ -44,7 +44,7 @@
 
         // Resize the iframe to the height of the content and bottom scrollbar height
         setHeight();
-        
+
         // Resize the iframe when the content changes
         const resizeObserver = new ResizeObserver((entries) => {
           setHeight();
@@ -55,7 +55,7 @@
     <marimo-filename hidden>notebook.py</marimo-filename>
     <marimo-mode data-mode='read' hidden></marimo-mode>
     <marimo-version data-version='0.0.0' hidden></marimo-version>
-    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' data-overrides='{"formatting": {"line_length": 100}}' hidden></marimo-user-config>
     <marimo-app-config data-config='{"css_file": "custom.css", "width": "compact"}' hidden></marimo-app-config>
     <marimo-server-token data-token='token' hidden></marimo-server-token>
     <title>notebook</title>
diff --git a/tests/_server/templates/snapshots/export5.txt b/tests/_server/templates/snapshots/export5.txt
index bcd2a29e37d..8b3eedf5169 100644
--- a/tests/_server/templates/snapshots/export5.txt
+++ b/tests/_server/templates/snapshots/export5.txt
@@ -24,7 +24,7 @@
     <script data-marimo="true">
       function __resizeIframe(obj) {
         var scrollbarHeight = 20; // Max between windows, mac, and linux
-        
+
         function setHeight() {
           var element = obj.contentWindow.document.documentElement;
           // If there is no vertical scrollbar, we don't need to resize the iframe
@@ -44,7 +44,7 @@
 
         // Resize the iframe to the height of the content and bottom scrollbar height
         setHeight();
-        
+
         // Resize the iframe when the content changes
         const resizeObserver = new ResizeObserver((entries) => {
           setHeight();
@@ -55,7 +55,7 @@
     <marimo-filename hidden>notebook.py</marimo-filename>
     <marimo-mode data-mode='read' hidden></marimo-mode>
     <marimo-version data-version='0.0.0' hidden></marimo-version>
-    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' hidden></marimo-user-config>
+    <marimo-user-config data-config='{"completion": {"activate_on_typing": true, "copilot": false}, "display": {"cell_output": "above", "code_editor_font_size": 14, "dataframes": "rich", "default_width": "medium", "theme": "light"}, "formatting": {"line_length": 79}, "keymap": {"overrides": {}, "preset": "default"}, "package_management": {"manager": "pip"}, "runtime": {"auto_instantiate": true, "auto_reload": "off", "on_cell_change": "autorun"}, "save": {"autosave": "after_delay", "autosave_delay": 1000, "format_on_save": false}, "server": {"browser": "default", "follow_symlink": false}}' data-overrides='{"formatting": {"line_length": 100}}' hidden></marimo-user-config>
     <marimo-app-config data-config='{"html_head_file": "head.html", "width": "compact"}' hidden></marimo-app-config>
     <marimo-server-token data-token='token' hidden></marimo-server-token>
     <title>notebook</title>
diff --git a/tests/_server/templates/test_templates.py b/tests/_server/templates/test_templates.py
index 95ddf10da2c..28de3d3aef3 100644
--- a/tests/_server/templates/test_templates.py
+++ b/tests/_server/templates/test_templates.py
@@ -6,7 +6,7 @@
 
 from marimo._ast.app import _AppConfig
 from marimo._ast.cell import CellConfig
-from marimo._config.config import DEFAULT_CONFIG
+from marimo._config.config import DEFAULT_CONFIG, PartialMarimoConfig
 from marimo._messaging.cell_output import CellChannel, CellOutput
 from marimo._server.export.exporter import hash_code
 from marimo._server.model import SessionMode
@@ -29,6 +29,7 @@ def setUp(self) -> None:
 
         self.base_url = "/subpath"
         self.user_config = DEFAULT_CONFIG
+        self.config_overrides: PartialMarimoConfig = {}
         self.server_token = SkewProtectionToken("token")
         self.app_config = _AppConfig()
         self.filename = "notebook.py"
@@ -39,6 +40,7 @@ def test_notebook_page_template(self) -> None:
             self.html,
             self.base_url,
             self.user_config,
+            self.config_overrides,
             self.server_token,
             self.app_config,
             self.filename,
@@ -55,6 +57,7 @@ def test_notebook_page_template_no_filename(self) -> None:
             self.html,
             self.base_url,
             self.user_config,
+            self.config_overrides,
             self.server_token,
             self.app_config,
             None,
@@ -71,6 +74,7 @@ def test_notebook_page_template_edit_mode(self) -> None:
             self.html,
             self.base_url,
             self.user_config,
+            self.config_overrides,
             self.server_token,
             self.app_config,
             self.filename,
@@ -95,6 +99,7 @@ def test_notebook_page_template_custom_css(self) -> None:
                 self.html,
                 self.base_url,
                 self.user_config,
+                self.config_overrides,
                 self.server_token,
                 _AppConfig(css_file="custom.css"),
                 self.filename,
@@ -128,6 +133,7 @@ def test_notebook_page_template_custom_head(self) -> None:
                 self.html,
                 self.base_url,
                 self.user_config,
+                self.config_overrides,
                 self.server_token,
                 _AppConfig(html_head_file="head.html"),
                 self.filename,
@@ -150,6 +156,9 @@ def setUp(self) -> None:
 
         self.base_url = "/subpath"
         self.user_config = DEFAULT_CONFIG
+        self.config_overrides: PartialMarimoConfig = {
+            "formatting": {"line_length": 100},
+        }
         self.server_token = SkewProtectionToken("token")
 
     def test_home_page_template(self) -> None:
@@ -157,6 +166,7 @@ def test_home_page_template(self) -> None:
             self.html,
             self.base_url,
             self.user_config,
+            self.config_overrides,
             self.server_token,
         )
 
@@ -179,6 +189,9 @@ def setUp(self) -> None:
             self.html = f.read()
 
         self.user_config = DEFAULT_CONFIG
+        self.config_overrides: PartialMarimoConfig = {
+            "formatting": {"line_length": 100},
+        }
         self.server_token = SkewProtectionToken("token")
         self.app_config = _AppConfig()
         self.filename = "notebook.py"
@@ -219,6 +232,7 @@ def test_static_notebook_template(self) -> None:
         result = templates.static_notebook_template(
             self.html,
             self.user_config,
+            self.config_overrides,
             self.server_token,
             self.app_config,
             self.filepath,
@@ -239,6 +253,7 @@ def test_static_notebook_template_no_filename(self) -> None:
         result = templates.static_notebook_template(
             self.html,
             self.user_config,
+            self.config_overrides,
             self.server_token,
             self.app_config,
             None,
@@ -259,6 +274,7 @@ def test_static_notebook_template_no_code(self) -> None:
         result = templates.static_notebook_template(
             self.html,
             self.user_config,
+            self.config_overrides,
             self.server_token,
             self.app_config,
             self.filepath,
@@ -287,6 +303,7 @@ def test_static_notebook_template_with_css(self) -> None:
             result = templates.static_notebook_template(
                 self.html,
                 self.user_config,
+                self.config_overrides,
                 self.server_token,
                 _AppConfig(css_file="custom.css"),
                 self.filename,
@@ -327,6 +344,7 @@ def test_static_notebook_template_with_head(self) -> None:
             result = templates.static_notebook_template(
                 self.html,
                 self.user_config,
+                self.config_overrides,
                 self.server_token,
                 _AppConfig(html_head_file="head.html"),
                 self.filename,
diff --git a/tests/_server/test_session_manager.py b/tests/_server/test_session_manager.py
index ccafd7d0df1..3329083f55d 100644
--- a/tests/_server/test_session_manager.py
+++ b/tests/_server/test_session_manager.py
@@ -4,7 +4,7 @@
 
 import pytest
 
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import get_default_config_manager
 from marimo._server.file_manager import AppFileManager
 from marimo._server.file_router import AppFileRouter
 from marimo._server.model import ConnectionState, SessionConsumer, SessionMode
@@ -34,7 +34,7 @@ def session_manager():
         quiet=False,
         include_code=True,
         lsp_server=MagicMock(spec=LspServer),
-        user_config_manager=UserConfigManager(),
+        user_config_manager=get_default_config_manager(current_path=None),
         cli_args={},
         auth_token=None,
     )
diff --git a/tests/_server/test_sessions.py b/tests/_server/test_sessions.py
index e2523a7f96f..e47635f9fa0 100644
--- a/tests/_server/test_sessions.py
+++ b/tests/_server/test_sessions.py
@@ -12,7 +12,7 @@
 from unittest.mock import MagicMock
 
 from marimo._ast.app import App, InternalApp
-from marimo._config.manager import UserConfigManager
+from marimo._config.manager import get_default_config_manager
 from marimo._runtime.requests import (
     AppMetadata,
     CreationRequest,
@@ -74,7 +74,7 @@ def test_kernel_manager_run_mode() -> None:
         mode,
         {},
         app_metadata,
-        UserConfigManager(),
+        get_default_config_manager(current_path=None),
         virtual_files_supported=True,
     )
 
@@ -106,7 +106,7 @@ def test_kernel_manager_edit_mode() -> None:
         mode,
         {},
         app_metadata,
-        UserConfigManager(),
+        get_default_config_manager(current_path=None),
         virtual_files_supported=True,
     )
 
@@ -137,7 +137,7 @@ def test_kernel_manager_interrupt(tmp_path) -> None:
         mode,
         {},
         app_metadata,
-        UserConfigManager(),
+        get_default_config_manager(current_path=None),
         virtual_files_supported=True,
     )
 
@@ -221,7 +221,7 @@ def test_session() -> None:
         SessionMode.RUN,
         {},
         app_metadata,
-        UserConfigManager(),
+        get_default_config_manager(current_path=None),
         virtual_files_supported=True,
     )
 
@@ -265,7 +265,7 @@ def test_session_disconnect_reconnect() -> None:
         SessionMode.RUN,
         {},
         AppMetadata(query_params={}, cli_args={}),
-        UserConfigManager(),
+        get_default_config_manager(current_path=None),
         virtual_files_supported=True,
     )
 
@@ -320,7 +320,7 @@ def test_session_with_kiosk_consumers() -> None:
         SessionMode.RUN,
         {},
         app_metadata,
-        UserConfigManager(),
+        get_default_config_manager(current_path=None),
         virtual_files_supported=True,
     )
 


Code review comments:
diff_hunk: @@ -65,19 +66,46 @@ marimo searches for the `.marimo.toml` file in the following order:
 If no `.marimo.toml` file is found, marimo creates one for you in an XDG config
 compliant way.
 
-View your current configuration and locate the config file with:
+To view your current configuration and locate the config file, run:
 
 ```bash
 marimo config show
 ```
 
+To describe the user configuration options, run:
+
+```bash
+marimo config describe
+```
+
+### Overriding settings with pyproject.toml
+
+You can override user configuration settings with a `pyproject.toml` file. This
+is useful for sharing configurations across teams or ensuring consistency across
+notebooks. You must edit the `pyproject.toml` file directly to override settings.
+
+For example, the following `pyproject.toml` file overrides the `autosave` setting
+in the user configuration:
+
+```toml
+[tool.marimo.save]
+autosave = "after_delay"
path: docs/guides/configuration/index.md
text: @user1:
Small thing. Should this example be tailored to things teams might want to standardize? Such as

```toml
[tool.marimo.format]
line_length = 120

[tool.marimo.display]
default_width = "full"
```

diff_hunk: @@ -23,12 +29,96 @@ def show() -> None:
     """
     import tomlkit
 
-    config_manager = UserConfigManager()
+    current_path = os.getcwd()
+
+    config_manager = get_default_config_manager(current_path=current_path)
     # Save config if doesn't exist
-    config_manager.save_config_if_missing()
-    echo(f"User config from {green(config_manager.get_config_path())}\n")
-    toml_string = tomlkit.dumps(config_manager.get_config())
-    echo(highlight_toml_headers(toml_string))
+    UserConfigManager().save_config_if_missing()
+
+    # Show project overrides if they exist
+    project_config_path = find_nearest_pyproject_toml(current_path)
+    overrides = config_manager.get_config_overrides()
+    if overrides:
+        echo(
+            f"\n📁 Project overrides from {green(str(project_config_path))}\n"
+        )
+        echo(highlight_toml_headers(tomlkit.dumps(overrides)))
+
+    # Show user config
+    user_config_path = config_manager.user_config_mgr.get_config_path()
+    echo(f"\n🏠 User config from {green(user_config_path)}\n")
+    user_config = config_manager.get_user_config()
+    echo(highlight_toml_headers(tomlkit.dumps(user_config)))
+
+
+@click.command(help="""Describe the marimo config""")
+def describe() -> None:
+    """Print documentation for all config options."""
+    import inspect
+    from textwrap import indent
+
+    import marimo._config.config as marimo_config
+    from marimo._cli.print import echo, green, muted, yellow
+
+    def format_type_docs(cls: type, indent_level: int = 0) -> str:
+        """Recursively format type documentation."""
+        output: list[str] = []
+        # Get annotations and docs from the class
+        annotations = (
+            cls.__annotations__ if hasattr(cls, "__annotations__") else {}
+        )
+
+        # Get the docstring
+        doc = inspect.getdoc(cls)
+        if doc and indent_level == 0:
+            output.append(muted(f"# {doc}\n"))
+
+        for key, type_hint in annotations.items():
+            # Get field documentation if available
+            if hasattr(type_hint, "__forward_arg__"):
+                type_hint = type_hint.__forward_arg__
+                # Import from marimo._config.config
+                # If NotRequired, we need to get the type from the NotRequired
+                if str(type_hint).startswith("NotRequired["):
+                    type_hint = type_hint[12:-1]
+
+                if type_hint in marimo_config.__dict__ and "TypedDict" in str(
+                    type(marimo_config.__dict__[type_hint])
+                ):
+                    config = marimo_config.__dict__[type_hint]
+                    field_output = f"[{green(key)}]"
+                    output.append(indent(field_output, "  " * indent_level))
+                    # Get the docstring for the nested TypedDict
+                    nested_doc = inspect.getdoc(config)
+                    if nested_doc:
+                        output.append(
+                            indent(
+                                muted(f"# {nested_doc}"),
+                                "  " * (indent_level + 1),
+                            )
+                        )
+                        output.append("")
+                    output.append(
+                        indent(
+                            format_type_docs(config, indent_level + 1),
+                            "  " * indent_level,
+                        )
+                    )
+                else:
+                    output.append(
+                        indent(
+                            f"{yellow(key)} = {type_hint}", "  " * indent_level
path: marimo/_cli/config/commands.py
text: @user1:
nit

```suggestion
                            f"{yellow(key)}: {type_hint}", "  " * indent_level
```