Repository: marimo-team/marimo
Pull request: feat: mo.cli_args
Fixes #1137 


Use `mo.cli_args` to access command-line arguments passed to the notebook. This allows you to pass arguments to the notebook that are not controllable by the user. The arguments will be parsed from the command line when running the notebook as an application with `marimo run` or `marimo edit`. These arguments are passed after the notebook name and are separated from the notebook arguments by a double dash `--`.

Some examples passing command-line arguments to the notebook:

```bash
marimo run app.py -- --arg1 value1 --arg2 value2
# {'arg1': 'value1', 'arg2': 'value2'}

marimo run app.py -- --arg1=10 --arg2=true --arg3
# {'arg1': 10, 'arg2': True, 'arg3': ''}

marimo run app.py -- --arg1 10.5 --arg2 hello --arg2 world
# {'arg1': 10.5, 'arg2': ['hello', 'world']}
```


Problem statement:
Provide non-Marimo command-line arguments to app
### Description

Access a filtered list of command-line arguments that were not used by Marimo, mostly to configure notebooks running as scripts.

### Suggested solution

Provide some API, e.g. `mo.args()`, similar to `mo.query_params()`, that returns a list of arguments that Marimo didn't use to configure itself. Perhaps a separator like `--` could explicitly separate Marimo from non-Marimo arguments.

This list could then be parsed with normal `argparse` like `parser.parse_args(args = mo.args())`.

### Alternative

`sys.argv` is definitely accessible, but contains a mixture of Marimo and non-Marimo (if provided) arguments and filtering these in the notebook would break as Marimo's API changes.

### Additional context

_No response_

Hints from issue discussion:
+1
I think it'd be ideal if query_params and args shared the command
I sketched this out a bit a while back. I didn't want to combine the two implicitly because the author can control the CLI args, while the user can control the query-params, and by default it would be nice to keep those permissions separate. Also the casing can be different based on typical usage (kebab for CLI and various for query-params). Also it's hard to pick one when they conflict

So instead, I wanted to add a util that can wrap and combine these smartly, with validation.

```python
arg = mo.cli_args() # just the args, plain
query_params = mo.quey_params() # just the query params, plain

combined = mo.args([
     mo.cli_args(),
     mo.env_variables(),
     mo.quey_params(), # query-params will overwrite any cli args
], casing="kebab")

# validated
combined = mo.args([
     mo.cli_args(),
     mo.env_variables(),
     mo.quey_params(),
], casing="kebab", validate=PydanticModel]
```

Commit message: Apply suggestions from code review

Patch to review:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 8cbed43481b..13a115c2d28 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -66,7 +66,7 @@ jobs:
   test_python:
     name: Tests on ${{ matrix.os }}, Python ${{ matrix.python-version }} with ${{ matrix.dependencies }} dependencies
     runs-on: ${{ matrix.os }}
-    timeout-minutes: 10
+    timeout-minutes: 15
     defaults:
       run:
         shell: bash
diff --git a/docs/api/cli_args.md b/docs/api/cli_args.md
new file mode 100644
index 00000000000..0bee8b46726
--- /dev/null
+++ b/docs/api/cli_args.md
@@ -0,0 +1,27 @@
+# Command Line Arguments
+
+Use `mo.cli_args` to access command-line arguments passed to the notebook. This allows you to pass arguments to the notebook that are not controllable by the user. The arguments will be parsed from the command line when running the notebook as an application with `marimo run` or `marimo edit`. These arguments are passed after the notebook name and are separated from the notebook arguments by a double dash `--`.
+
+Some examples passing command-line arguments to the notebook:
+
+```bash
+marimo run app.py -- --arg1 value1 --arg2 value2
+# {'arg1': 'value1', 'arg2': 'value2'}
+
+marimo run app.py -- --arg1=10 --arg2=true --arg3
+# {'arg1': 10, 'arg2': True, 'arg3': ''}
+
+marimo run app.py -- --arg1 10.5 --arg2 hello --arg2 world
+# {'arg1': 10.5, 'arg2': ['hello', 'world']}
+```
+
+```{eval-rst}
+.. autofunction:: marimo.cli_args
+```
+
+```{admonition} Query Parameters
+:class: note
+
+You can also access query parameters passed to the notebook using
+`mo.query_params`. This allows you to pass arguments to the notebook that can be controlled by the user.
+```
diff --git a/docs/api/index.md b/docs/api/index.md
index a1ba01549fc..7a2cfe81ba6 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -16,6 +16,7 @@
   control_flow
   html
   query_params
+  cli_args
   state
   cell
   debugging
@@ -42,6 +43,7 @@ Use the marimo library in marimo notebooks (`import marimo as mo`) to
 | {doc}`control_flow`  | Control how cells execute                                 |
 | {doc}`html`          | Manipulate HTML objects                                   |
 | {doc}`query_params`  | Access and set query parameters with `mo.query_params`    |
+| {doc}`cli_args`      | Access command-line arguments with `mo.cli_args`          |
 | {doc}`state`         | Synchronize multiple UI elements with `mo.state`          |
 | {doc}`cell`          | Run cells defined in another notebook                     |
 | {doc}`debugging`     | Debugging utilities                                       |
diff --git a/docs/api/query_params.md b/docs/api/query_params.md
index 4cc5016f511..8eae214ab51 100644
--- a/docs/api/query_params.md
+++ b/docs/api/query_params.md
@@ -8,3 +8,10 @@ state of the notebook while running as an application with `marimo run`.
 ```{eval-rst}
 .. autofunction:: marimo.query_params
 ```
+
+```{admonition} CLI arguments
+:class: note
+
+You can also access command-line arguments passed to the notebook using
+`mo.cli_args`. This allows you to pass arguments to the notebook that are not controllable by the user.
+```
diff --git a/marimo/__init__.py b/marimo/__init__.py
index fd79aa56450..ba48035f75c 100644
--- a/marimo/__init__.py
+++ b/marimo/__init__.py
@@ -25,8 +25,8 @@
     "capture_stdout",
     "capture_stderr",
     "center",
+    "cli_args",
     "defs",
-    "query_params",
     "doc",
     "download",
     "hstack",
@@ -41,6 +41,7 @@
     "output",
     "plain_text",
     "pdf",
+    "query_params",
     "redirect_stderr",
     "redirect_stdout",
     "refs",
@@ -92,6 +93,6 @@
     redirect_stdout,
 )
 from marimo._runtime.control_flow import MarimoStopError, stop
-from marimo._runtime.runtime import defs, query_params, refs
+from marimo._runtime.runtime import cli_args, defs, query_params, refs
 from marimo._runtime.state import state
 from marimo._server.asgi import create_asgi_app
diff --git a/marimo/_cli/cli.py b/marimo/_cli/cli.py
index 00ef8774673..33d111402ae 100644
--- a/marimo/_cli/cli.py
+++ b/marimo/_cli/cli.py
@@ -17,6 +17,7 @@
 from marimo._cli.envinfo import get_system_info
 from marimo._cli.export.commands import export
 from marimo._cli.file_path import validate_name
+from marimo._cli.parse_args import parse_args
 from marimo._cli.print import red
 from marimo._cli.upgrade import check_for_updates
 from marimo._server.file_router import AppFileRouter
@@ -181,11 +182,13 @@ def main(log_level: str, quiet: bool, development_mode: bool) -> None:
     help="Don't launch a browser.",
 )
 @click.argument("name", required=False)
+@click.argument("args", nargs=-1, type=click.UNPROCESSED)
 def edit(
     port: Optional[int],
     host: str,
     headless: bool,
-    name: Optional[str] = None,
+    name: Optional[str],
+    args: tuple[str],
 ) -> None:
     # Check for version updates
     check_for_updates()
@@ -223,6 +226,7 @@ def edit(
         mode=SessionMode.EDIT,
         include_code=True,
         watch=False,
+        cli_args=parse_args(args),
     )
 
 
@@ -265,6 +269,7 @@ def new(
         mode=SessionMode.EDIT,
         include_code=True,
         watch=False,
+        cli_args={},
     )
 
 
@@ -331,14 +336,16 @@ def new(
     callback=validators.base_url,
 )
 @click.argument("name", required=True)
+@click.argument("args", nargs=-1, type=click.UNPROCESSED)
 def run(
     port: Optional[int],
     host: str,
     headless: bool,
     include_code: bool,
     watch: bool,
-    name: str,
     base_url: str,
+    name: str,
+    args: tuple[str],
 ) -> None:
     # Validate name, or download from URL
     # The second return value is an optional temporary directory. It is unused,
@@ -360,6 +367,7 @@ def run(
         include_code=include_code,
         watch=watch,
         base_url=base_url,
+        cli_args=parse_args(args),
     )
 
 
@@ -490,6 +498,7 @@ def tutorial(
         include_code=True,
         headless=headless,
         watch=False,
+        cli_args={},
     )
 
 
diff --git a/marimo/_cli/parse_args.py b/marimo/_cli/parse_args.py
new file mode 100644
index 00000000000..4551cbb8c00
--- /dev/null
+++ b/marimo/_cli/parse_args.py
@@ -0,0 +1,74 @@
+# Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
+from typing import Any
+
+from marimo._runtime.requests import SerializedCLIArgs
+
+
+def parse_args(
+    args: tuple[str],
+) -> SerializedCLIArgs:
+    """
+    Parse command line arguments into a dictionary.
+
+    This does not support lists as values.
+    """
+
+    args_dict: SerializedCLIArgs = {}
+
+    # Combine any arguments that are split by spaces
+    new_args: list[str] = []
+    for arg in args:
+        if arg.startswith(("-", "--")):
+            new_args.append(arg)
+        else:
+            new_args[-1] += f" {arg}"
+
+    for arg in new_args:
+        if arg.startswith(("-", "--")):
+            # Strip leading dashes
+            arg = arg.lstrip("-")
+            key: str
+            value: Any
+
+            if "=" in arg:
+                key, value = arg.split("=", 1)
+            elif " " in arg:
+                key, value = arg.split(" ", 1)
+                key = key.strip()
+                value = value.strip()
+            else:
+                key = arg
+                value = ""
+
+            # Try numeric conversion
+            try:
+                value = int(value)
+            except ValueError:
+                try:
+                    value = float(value)
+                except ValueError:
+                    pass
+
+            # Try boolean conversion
+            if value == "True":
+                value = True
+            elif value == "true":
+                value = True
+            elif value == "false":
+                value = False
+            elif value == "False":
+                value = False
+
+            # Create a list for duplicate arguments
+            if key in args_dict:
+                current = args_dict[key]
+                if isinstance(current, list):
+                    current.append(value)
+                else:
+                    args_dict[key] = [current, value]
+            else:
+                args_dict[key] = value
+
+    return args_dict
diff --git a/marimo/_output/data/data.py b/marimo/_output/data/data.py
index 5e4ac2f653f..d35815d3526 100644
--- a/marimo/_output/data/data.py
+++ b/marimo/_output/data/data.py
@@ -4,7 +4,6 @@
 from typing import Union
 
 from marimo._plugins.core.media import is_data_empty
-from marimo._runtime.context import get_context
 from marimo._runtime.virtual_file import (
     EMPTY_VIRTUAL_FILE,
     VirtualFile,
@@ -24,7 +23,7 @@ def pdf(data: bytes) -> VirtualFile:
     A `VirtualFile` object.
     """
     item = VirtualFileLifecycleItem(ext="pdf", buffer=data)
-    get_context().cell_lifecycle_registry.add(item)
+    item.add_to_cell_lifecycle_registry()
     return item.virtual_file
 
 
@@ -40,7 +39,7 @@ def image(data: bytes, ext: str = "png") -> VirtualFile:
     A `VirtualFile` object.
     """
     item = VirtualFileLifecycleItem(ext=ext, buffer=data)
-    get_context().cell_lifecycle_registry.add(item)
+    item.add_to_cell_lifecycle_registry()
     return item.virtual_file
 
 
@@ -56,7 +55,7 @@ def audio(data: bytes, ext: str = "wav") -> VirtualFile:
     A `VirtualFile` object.
     """
     item = VirtualFileLifecycleItem(ext=ext, buffer=data)
-    get_context().cell_lifecycle_registry.add(item)
+    item.add_to_cell_lifecycle_registry()
     return item.virtual_file
 
 
@@ -144,7 +143,7 @@ def any_data(data: Union[str, bytes, io.BytesIO], ext: str) -> VirtualFile:
         base64str = data.split(",")[1]
         buffer = base64.b64decode(base64str)
         item = VirtualFileLifecycleItem(ext=ext, buffer=buffer)
-        get_context().cell_lifecycle_registry.add(item)
+        item.add_to_cell_lifecycle_registry()
         return item.virtual_file
 
     # URL
@@ -154,13 +153,13 @@ def any_data(data: Union[str, bytes, io.BytesIO], ext: str) -> VirtualFile:
     # Bytes
     if isinstance(data, bytes):
         item = VirtualFileLifecycleItem(ext=ext, buffer=data)
-        get_context().cell_lifecycle_registry.add(item)
+        item.add_to_cell_lifecycle_registry()
         return item.virtual_file
 
     # String
     if isinstance(data, str):
         item = VirtualFileLifecycleItem(ext=ext, buffer=data.encode("utf-8"))
-        get_context().cell_lifecycle_registry.add(item)
+        item.add_to_cell_lifecycle_registry()
         return item.virtual_file
 
     # BytesIO
@@ -168,7 +167,7 @@ def any_data(data: Union[str, bytes, io.BytesIO], ext: str) -> VirtualFile:
         # clone before reading, so we don't consume the stream
         buffer = io.BytesIO(data.getvalue()).read()
         item = VirtualFileLifecycleItem(ext=ext, buffer=buffer)
-        get_context().cell_lifecycle_registry.add(item)
+        item.add_to_cell_lifecycle_registry()
         return item.virtual_file
 
     raise ValueError(f"Unsupported data type: {type(data)}")
diff --git a/marimo/_pyodide/pyodide_session.py b/marimo/_pyodide/pyodide_session.py
index 73905da8adb..6e7b77b3a36 100644
--- a/marimo/_pyodide/pyodide_session.py
+++ b/marimo/_pyodide/pyodide_session.py
@@ -94,7 +94,9 @@ def write_kernel_message(op: KernelMessage) -> None:
 
     app_file_manager = AppFileManager(filename=filename)
     app = app_file_manager.app
-    app_metadata = AppMetadata(query_params=query_params, filename=filename)
+    app_metadata = AppMetadata(
+        query_params=query_params, filename=filename, cli_args={}
+    )
 
     session = PyodideSession(
         app_file_manager,
diff --git a/marimo/_runtime/functions.py b/marimo/_runtime/functions.py
index 0b523b05a64..ebcc28a0bb9 100644
--- a/marimo/_runtime/functions.py
+++ b/marimo/_runtime/functions.py
@@ -33,13 +33,20 @@ def __init__(
         arg_cls: Type[S],
         function: Callable[[S], T],
     ) -> None:
-        from marimo._runtime.context import get_context
+        from marimo._runtime.context import (
+            ContextNotInitializedError,
+            get_context,
+        )
 
         self.name = name
         self.arg_cls = arg_cls
         self.function = function
 
-        ctx = get_context()
+        try:
+            ctx = get_context()
+        except ContextNotInitializedError:
+            ctx = None
+
         if ctx is not None and ctx.kernel.execution_context is not None:
             self.cell_id = ctx.kernel.execution_context.cell_id
         else:
diff --git a/marimo/_runtime/query_params.py b/marimo/_runtime/params.py
similarity index 68%
rename from marimo/_runtime/query_params.py
rename to marimo/_runtime/params.py
index 60d19a5e17c..3bdbff46241 100644
--- a/marimo/_runtime/query_params.py
+++ b/marimo/_runtime/params.py
@@ -1,6 +1,9 @@
 # Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
 from typing import Dict, Iterator, List, Optional, Union
 
+from marimo._messaging.mimetypes import KnownMimeType
 from marimo._messaging.ops import (
     QueryParamsAppend,
     QueryParamsClear,
@@ -9,7 +12,12 @@
 )
 from marimo._messaging.types import Stream
 from marimo._output.rich_help import mddoc
-from marimo._runtime.requests import SerializedQueryParams
+from marimo._runtime.requests import (
+    ListOrValue,
+    Primitive,
+    SerializedCLIArgs,
+    SerializedQueryParams,
+)
 from marimo._runtime.state import State
 
 
@@ -117,6 +125,11 @@ def remove(self, key: str, value: Optional[str] = None) -> None:
         QueryParamsDelete(key, value).broadcast(self._stream)
         self._set_value(self._params)
 
+    def _mime_(self) -> tuple[KnownMimeType, str]:
+        from marimo._plugins.stateless.tree import tree
+
+        return tree(self._params)._mime_()
+
     def clear(self) -> None:
         """Clear all query params."""
         self._params.clear()
@@ -125,3 +138,58 @@ def clear(self) -> None:
 
     def to_dict(self) -> Dict[str, Union[str, List[str]]]:
         return self._params
+
+
+@mddoc
+class CLIArgs:
+    """CLI args passed to a marimo app."""
+
+    def __init__(
+        self,
+        params: SerializedCLIArgs,
+    ):
+        self._params = params
+
+    def get(self, key: str) -> Optional[ListOrValue[Primitive]]:
+        """Get the value of the CLI arg.
+
+        Returns a singleton value if there is only one item, a list of values otherwise.
+        """
+        if key not in self._params:
+            return None
+        return self._params[key]
+
+    def get_all(self, key: str) -> List[Primitive]:
+        """Get the value of a CLI arg as a list."""
+        value = self._params.get(key)
+        if value is None:
+            return []
+        if isinstance(value, list):
+            return value
+        return [value]
+
+    def __getitem__(self, key: str) -> Optional[ListOrValue[Primitive]]:
+        return self.get(key)
+
+    def __contains__(self, key: str) -> bool:
+        return key in self._params
+
+    def __len__(self) -> int:
+        return len(self._params)
+
+    def __iter__(self) -> Iterator[ListOrValue[Primitive]]:
+        return iter(self._params)
+
+    def __repr__(self) -> str:
+        return f"CLIArgs({self._params})"
+
+    def __str__(self) -> str:
+        return str(self._params)
+
+    def _mime_(self) -> tuple[KnownMimeType, str]:
+        from marimo._plugins.stateless.tree import tree
+
+        return tree(self._params)._mime_()
+
+    def to_dict(self) -> SerializedCLIArgs:
+        return self._params
diff --git a/marimo/_runtime/requests.py b/marimo/_runtime/requests.py
index c46c9c4819f..676614a1231 100644
--- a/marimo/_runtime/requests.py
+++ b/marimo/_runtime/requests.py
@@ -2,7 +2,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass
-from typing import Any, Dict, List, Optional, Tuple, Union
+from typing import Any, Dict, List, Optional, Tuple, TypeVar, Union
 
 from marimo._ast.cell import CellId_t
 from marimo._config.config import MarimoConfig
@@ -11,7 +11,11 @@
 CompletionRequestId = str
 FunctionCallId = str
 
-SerializedQueryParams = Dict[str, Union[str, List[str]]]
+T = TypeVar("T")
+ListOrValue = Union[T, List[T]]
+SerializedQueryParams = Dict[str, ListOrValue[str]]
+Primitive = Union[str, bool, int, float]
+SerializedCLIArgs = Dict[str, ListOrValue[Union[Primitive]]]
 
 
 @dataclass
@@ -48,6 +52,7 @@ class AppMetadata:
     """Hold metadata about the app, like its filename."""
 
     query_params: SerializedQueryParams
+    cli_args: SerializedCLIArgs
 
     filename: Optional[str] = None
 
diff --git a/marimo/_runtime/runtime.py b/marimo/_runtime/runtime.py
index c1cc92bf0ae..87093f17216 100644
--- a/marimo/_runtime/runtime.py
+++ b/marimo/_runtime/runtime.py
@@ -84,7 +84,7 @@
 from marimo._runtime.packages.package_manager import PackageManager
 from marimo._runtime.packages.package_managers import create_package_manager
 from marimo._runtime.packages.utils import is_python_isolated
-from marimo._runtime.query_params import QueryParams
+from marimo._runtime.params import CLIArgs, QueryParams
 from marimo._runtime.redirect_streams import redirect_streams
 from marimo._runtime.reload.autoreload import ModuleReloader
 from marimo._runtime.reload.module_watcher import ModuleWatcher
@@ -215,6 +215,28 @@ def query_params() -> QueryParams:
     return get_context().kernel.query_params
 
 
+@mddoc
+def cli_args() -> CLIArgs:
+    """Get the command line arguments of a marimo app.
+
+    **Examples**:
+
+    ```python3
+    # Access the command line arguments
+    size = mo.cli_args().get("size") or 100
+
+    for i in range(size):
+        print(i)
+    ```
+
+    **Returns**:
+
+    - A dictionary containing the command line arguments.
+      This dictionary is read-only and cannot be mutated.
+    """
+    return get_context().kernel.cli_args
+
+
 @dataclasses.dataclass
 class ExecutionContext:
     cell_id: CellId_t
@@ -266,6 +288,7 @@ def __init__(
     ) -> None:
         self.app_metadata = app_metadata
         self.query_params = QueryParams(app_metadata.query_params)
+        self.cli_args = CLIArgs(app_metadata.cli_args)
         self.stream = stream
         self.stdout = stdout
         self.stderr = stderr
diff --git a/marimo/_runtime/virtual_file.py b/marimo/_runtime/virtual_file.py
index afa7e28c475..5bafdf506e1 100644
--- a/marimo/_runtime/virtual_file.py
+++ b/marimo/_runtime/virtual_file.py
@@ -14,6 +14,7 @@
 from marimo._messaging.mimetypes import KnownMimeType
 from marimo._output.utils import build_data_url
 from marimo._runtime.cell_lifecycle_item import CellLifecycleItem
+from marimo._runtime.context import ContextNotInitializedError
 from marimo._server.api.status import HTTPException, HTTPStatus
 from marimo._utils.platform import is_pyodide
 
@@ -100,18 +101,37 @@ def __init__(self, ext: str, buffer: bytes) -> None:
         # Not resolved until added to registry
         self._virtual_file: Optional[VirtualFile] = None
 
+    def add_to_cell_lifecycle_registry(self) -> None:
+        from marimo._runtime.context import get_context
+
+        try:
+            ctx = get_context()
+        except ContextNotInitializedError:
+            ctx = None
+
+        if ctx is not None:
+            ctx.cell_lifecycle_registry.add(self)
+        else:
+            self.create(context=None)
+
     @property
     def virtual_file(self) -> VirtualFile:
         assert self._virtual_file is not None
         return self._virtual_file
 
-    def create(self, context: "RuntimeContext") -> None:
+    def create(self, context: "RuntimeContext" | None) -> None:
         """Create the virtual file
 
         Every virtual file gets a unique random name. Uniqueness is
         required for reference counting.
         """
         filename = random_filename(self.ext)
+        if context is None or not context.virtual_files_supported:
+            self._virtual_file = VirtualFile(
+                filename="", buffer=self.buffer, as_data_url=True
+            )
+            return
+
         registry = context.virtual_file_registry
         # create a unique filename for the virtual file
         tries = 0
@@ -124,11 +144,7 @@ def create(self, context: "RuntimeContext") -> None:
                 "Failed to add virtual file to registry. "
                 "This is a bug in marimo. Please file an issue."
             )
-        self._virtual_file = VirtualFile(
-            filename,
-            self.buffer,
-            as_data_url=not context.virtual_files_supported,
-        )
+        self._virtual_file = VirtualFile(filename, self.buffer)
         context.virtual_file_registry.add(self._virtual_file, context)
 
     def dispose(self, context: "RuntimeContext", deletion: bool) -> bool:
diff --git a/marimo/_server/api/endpoints/ws.py b/marimo/_server/api/endpoints/ws.py
index b50053a4f82..9f54031a520 100644
--- a/marimo/_server/api/endpoints/ws.py
+++ b/marimo/_server/api/endpoints/ws.py
@@ -21,7 +21,7 @@
 from marimo._messaging.types import KernelMessage, NoopStream
 from marimo._plugins.core.json_encoder import WebComponentEncoder
 from marimo._plugins.core.web_component import JSONType
-from marimo._runtime.query_params import QueryParams
+from marimo._runtime.params import QueryParams
 from marimo._server.api.deps import AppState
 from marimo._server.file_router import MarimoFileKey
 from marimo._server.model import (
diff --git a/marimo/_server/asgi.py b/marimo/_server/asgi.py
index bea3148a436..3da19a3ef2f 100644
--- a/marimo/_server/asgi.py
+++ b/marimo/_server/asgi.py
@@ -123,6 +123,10 @@ def _build_app(self, path: str, root: str) -> "ASGIAppBuilder":
                 # which doesn't require an LSP server
                 lsp_server=NoopLspServer(),
                 user_config_manager=user_config_mgr,
+                # We don't pass any CLI args for now
+                # since we don't want to read arbitrary args and apply them
+                # to each application
+                cli_args={},
             )
             app = create_starlette_app(
                 base_url="",
diff --git a/marimo/_server/export/utils.py b/marimo/_server/export/utils.py
index 610d55b7f0e..8eedb144ae3 100644
--- a/marimo/_server/export/utils.py
+++ b/marimo/_server/export/utils.py
@@ -58,7 +58,9 @@ def connection_state(self) -> ConnectionState:
         initialization_id=file_key,
         session_consumer=NoopSessionConsumer(),
         mode=SessionMode.RUN,
-        app_metadata=AppMetadata(query_params={}, filename=file_manager.path),
+        app_metadata=AppMetadata(
+            query_params={}, filename=file_manager.path, cli_args={}
+        ),
         app_file_manager=file_manager,
         user_config_manager=config,
         virtual_files_supported=False,
diff --git a/marimo/_server/sessions.py b/marimo/_server/sessions.py
index 01f9109b9a9..d1defb49446 100644
--- a/marimo/_server/sessions.py
+++ b/marimo/_server/sessions.py
@@ -40,6 +40,7 @@
     AppMetadata,
     CreationRequest,
     ExecutionRequest,
+    SerializedCLIArgs,
     SerializedQueryParams,
     SetUIElementValueRequest,
 )
@@ -425,6 +426,7 @@ def __init__(
         include_code: bool,
         lsp_server: LspServer,
         user_config_manager: UserConfigManager,
+        cli_args: SerializedCLIArgs,
     ) -> None:
         self.file_router = file_router
         self.mode = mode
@@ -436,6 +438,7 @@ def __init__(
         self.watcher: Optional[FileWatcher] = None
         self.recents = RecentFilesManager()
         self.user_config_manager = user_config_manager
+        self.cli_args = cli_args
 
         if mode == SessionMode.EDIT:
             # In edit mode, the server gets a random token to prevent
@@ -476,7 +479,9 @@ def create_session(
                 session_consumer=session_consumer,
                 mode=self.mode,
                 app_metadata=AppMetadata(
-                    query_params=query_params, filename=app_file_manager.path
+                    query_params=query_params,
+                    filename=app_file_manager.path,
+                    cli_args=self.cli_args,
                 ),
                 app_file_manager=app_file_manager,
                 user_config_manager=self.user_config_manager,
diff --git a/marimo/_server/start.py b/marimo/_server/start.py
index 2d8cccf546a..9677b74960d 100644
--- a/marimo/_server/start.py
+++ b/marimo/_server/start.py
@@ -1,4 +1,6 @@
 # Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
 import os
 from typing import Optional
 
@@ -6,6 +8,7 @@
 
 import marimo._server.api.lifespans as lifespans
 from marimo._config.manager import UserConfigManager
+from marimo._runtime.requests import SerializedCLIArgs
 from marimo._server.file_router import AppFileRouter
 from marimo._server.main import create_starlette_app
 from marimo._server.model import SessionMode
@@ -34,6 +37,7 @@ def start(
     port: Optional[int],
     host: str,
     watch: bool,
+    cli_args: SerializedCLIArgs,
     base_url: str = "",
 ) -> None:
     """
@@ -53,6 +57,7 @@ def start(
         include_code=include_code,
         lsp_server=LspServer(port * 10),
         user_config_manager=user_config_mgr,
+        cli_args=cli_args,
     )
 
     log_level = "info" if development_mode else "error"
diff --git a/marimo/_smoke_tests/cli_args.py b/marimo/_smoke_tests/cli_args.py
new file mode 100644
index 00000000000..a986f54352b
--- /dev/null
+++ b/marimo/_smoke_tests/cli_args.py
@@ -0,0 +1,33 @@
+# Copyright 2024 Marimo. All rights reserved.
+import marimo
+
+__generated_with = "0.4.0"
+app = marimo.App()
+
+
+@app.cell
+def __():
+    import marimo as mo
+    return mo,
+
+
+@app.cell
+def __(mo):
+    mo.md("""
+    Re-run this with notebook with the following command line:
+
+    ```bash
+    marimo edit marimo/_smoke_tests/cli_args.py -- -a foo --b=bar -d 10 -d 20 -d false
+    ```
+    """)
+    return
+
+
+@app.cell
+def __(mo):
+    mo.cli_args().to_dict()
+    return
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_snippets/data/cli-args-0.py b/marimo/_snippets/data/cli-args-0.py
new file mode 100644
index 00000000000..0f4a8481695
--- /dev/null
+++ b/marimo/_snippets/data/cli-args-0.py
@@ -0,0 +1,41 @@
+# Copyright 2024 Marimo. All rights reserved.
+
+import marimo
+
+__generated_with = "0.4.0"
+app = marimo.App()
+
+
+@app.cell
+def __(mo):
+    mo.md(
+        r"""
+        # CLI Arguments: Reading CLI arguments
+
+        Use `mo.cli_args` to access command line arguments passed to the notebook.
+        For example, you can pass arguments to the notebook when running it as an
+        application with `marimo run`.
+
+        ```bash
+        marimo run app.py -- --arg1 value1 --arg2 value2
+        ```
+        """
+    )
+    return
+
+
+@app.cell
+def __(mo):
+    params = mo.cli_args()
+    params
+    return params,
+
+
+@app.cell
+def __():
+    import marimo as mo
+    return mo,
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_snippets/data/query-params-0.py b/marimo/_snippets/data/query-params-0.py
new file mode 100644
index 00000000000..3d2bb8a1a7f
--- /dev/null
+++ b/marimo/_snippets/data/query-params-0.py
@@ -0,0 +1,36 @@
+# Copyright 2024 Marimo. All rights reserved.
+
+import marimo
+
+__generated_with = "0.4.0"
+app = marimo.App()
+
+
+@app.cell
+def __(mo):
+    mo.md(
+        r"""
+        # Query Parameters: Reading query parameters
+
+        Use `mo.query_params` to access query parameters passed to the notebook.
+        """
+    )
+    return
+
+
+@app.cell
+def __(mo):
+    params = mo.query_params()
+    print(params)
+    return (params,)
+
+
+@app.cell
+def __():
+    import marimo as mo
+
+    return (mo,)
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/marimo/_snippets/data/query-params-1.py b/marimo/_snippets/data/query-params-1.py
new file mode 100644
index 00000000000..9f0d017b4bc
--- /dev/null
+++ b/marimo/_snippets/data/query-params-1.py
@@ -0,0 +1,59 @@
+# Copyright 2024 Marimo. All rights reserved.
+
+import marimo
+
+__generated_with = "0.4.0"
+app = marimo.App()
+
+
+@app.cell
+def __(mo):
+    mo.md(
+        r"""
+        # Query Parameters: Writing to query parameters
+
+        You can also use `mo.query_params` to set query parameters in order
+        to keep track of state in the URL. This is useful for bookmarking
+        or sharing a particular state of the notebook while running as an
+        application with `marimo run`.
+        """
+    )
+    return
+
+
+@app.cell
+def __(mo):
+    query_params = mo.query_params()
+    return query_params,
+
+
+@app.cell
+def __(mo, query_params):
+    slider = mo.ui.slider(
+        0,
+        10,
+        value=query_params.get("slider") or 1,
+        on_change=lambda x: query_params.set("slider", x),
+    )
+    slider
+    return slider,
+
+
+@app.cell
+def __(mo, query_params):
+    search = mo.ui.text(
+        value=query_params.get("search") or "",
+        on_change=lambda x: query_params.set("search", x),
+    )
+    search
+    return search,
+
+
+@app.cell
+def __():
+    import marimo as mo
+    return mo,
+
+
+if __name__ == "__main__":
+    app.run()
diff --git a/tests/_cli/test_cli.py b/tests/_cli/test_cli.py
index 453b81e41ce..cd9b6199514 100644
--- a/tests/_cli/test_cli.py
+++ b/tests/_cli/test_cli.py
@@ -5,6 +5,8 @@
 Requires frontend to be built
 """
 
+from __future__ import annotations
+
 import contextlib
 import os
 import signal
@@ -171,6 +173,28 @@ def test_cli_edit_new_file() -> None:
     _check_contents(p, b"marimo-server-token", contents)
 
 
+def test_cli_edit_with_additional_args(temp_marimo_file: str) -> None:
+    port = _get_port()
+    p = subprocess.Popen(
+        [
+            "marimo",
+            "edit",
+            temp_marimo_file,
+            "-p",
+            str(port),
+            "--headless",
+            "--",
+            "-a=foo",
+            "--b=bar",
+        ]
+    )
+    contents = _try_fetch(port)
+    _check_contents(p, b"marimo-mode data-mode='edit'", contents)
+    _check_contents(
+        p, f"marimo-version data-version='{__version__}'".encode(), contents
+    )
+
+
 def test_cli_new() -> None:
     port = _get_port()
     p = subprocess.Popen(["marimo", "new", "-p", str(port), "--headless"])
@@ -182,18 +206,10 @@ def test_cli_new() -> None:
     _check_contents(p, b"marimo-server-token", contents)
 
 
-def test_cli_run() -> None:
-    filecontents = codegen.generate_filecontents(
-        ["import marimo as mo"], ["one"], cell_configs=[CellConfig()]
-    )
-    d = tempfile.TemporaryDirectory()
-    path = os.path.join(d.name, "run.py")
-    with open(path, "w", encoding="utf-8") as f:
-        f.write(filecontents)
-
+def test_cli_run(temp_marimo_file: str) -> None:
     port = _get_port()
     p = subprocess.Popen(
-        ["marimo", "run", path, "-p", str(port), "--headless"]
+        ["marimo", "run", temp_marimo_file, "-p", str(port), "--headless"]
     )
     contents = _try_fetch(port)
     _check_contents(p, b"marimo-mode data-mode='read'", contents)
@@ -202,25 +218,39 @@ def test_cli_run() -> None:
     )
 
 
-def test_cli_run_with_show_code() -> None:
-    filecontents = codegen.generate_filecontents(
-        ["import marimo as mo"], ["one"], cell_configs=[CellConfig()]
+def test_cli_run_with_show_code(temp_marimo_file: str) -> None:
+    port = _get_port()
+    p = subprocess.Popen(
+        [
+            "marimo",
+            "run",
+            temp_marimo_file,
+            "-p",
+            str(port),
+            "--headless",
+            "--include-code",
+        ]
+    )
+    contents = _try_fetch(port)
+    _check_contents(p, b"marimo-mode data-mode='read'", contents)
+    _check_contents(
+        p, f"marimo-version data-version='{__version__}'".encode(), contents
     )
-    d = tempfile.TemporaryDirectory()
-    path = os.path.join(d.name, "run.py")
-    with open(path, "w", encoding="utf-8") as f:
-        f.write(filecontents)
 
+
+def test_cli_run_with_additional_args(temp_marimo_file: str) -> None:
     port = _get_port()
     p = subprocess.Popen(
         [
             "marimo",
             "run",
-            path,
+            temp_marimo_file,
             "-p",
             str(port),
             "--headless",
-            "--include-code",
+            "--",
+            "-a=foo",
+            "--b=bar",
         ]
     )
     contents = _try_fetch(port)
diff --git a/tests/_cli/test_parse_args.py b/tests/_cli/test_parse_args.py
new file mode 100644
index 00000000000..d1f15c480ad
--- /dev/null
+++ b/tests/_cli/test_parse_args.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from marimo._cli.parse_args import parse_args
+
+
+def test_parse_args_no_args():
+    assert parse_args(()) == {}  # type: ignore
+    assert parse_args(("")) == {}
+
+
+def test_parse_args_with_integer():
+    args = ("--count=10",)
+    expected = {"count": 10}
+    assert parse_args(args) == expected
+
+
+def test_parse_args_with_float():
+    args = ("--ratio=0.8",)
+    expected = {"ratio": 0.8}
+    assert parse_args(args) == expected
+
+
+def test_parse_args_with_boolean():
+    args: tuple[str] = ("--verbose=True", "--debug=false")
+    expected = {"verbose": True, "debug": False}
+    assert parse_args(args) == expected
+
+
+def test_parse_args_with_string():
+    args = ("--name=marimo",)
+    expected = {"name": "marimo"}
+    assert parse_args(args) == expected
+
+
+def test_parse_args_with_short_option():
+    args = ("-n", "--name", "-d", "--debug")
+    expected = {
+        "n": "",
+        "name": "",
+        "d": "",
+        "debug": "",
+    }
+    assert parse_args(args) == expected
+
+
+def test_parse_args_with_multiple_options():
+    args = ("--name=marimo", "--count=10", "--verbose=True")
+    expected = {"name": "marimo", "count": 10, "verbose": True}
+    assert parse_args(args) == expected
+
+
+def test_parse_args_with_spaces_or_equals():
+    args = ("--name=marimo is cool", "--count", "10", "--key=abc=")
+    expected = {"name": "marimo is cool", "count": 10, "key": "abc="}
+    assert parse_args(args) == expected
+
+
+def test_lists():
+    args = (
+        "--name=one",
+        "--count",
+        "10",
+        "10",
+        "--verbose=True",
+        "--name=two",
+        "--name=three",
+    )
+    expected = {
+        "name": ["one", "two", "three"],
+        "count": "10 10",
+        "verbose": True,
+    }
+    assert parse_args(args) == expected
diff --git a/tests/_runtime/reload/test_module_watcher.py b/tests/_runtime/reload/test_module_watcher.py
index b27ae4e3144..6e8214c9170 100644
--- a/tests/_runtime/reload/test_module_watcher.py
+++ b/tests/_runtime/reload/test_module_watcher.py
@@ -1,8 +1,10 @@
+from __future__ import annotations
+
+import asyncio
 import copy
 import pathlib
 import sys
 import textwrap
-import time
 from queue import Queue
 
 from reload_test_utils import random_modname, update_file
@@ -52,7 +54,7 @@ def foo():
     )
 
     # wait for the watcher to pick up the change
-    time.sleep(1.5)
+    await asyncio.sleep(1.5)
     assert k.graph.cells[er_1.cell_id].stale
     assert k.graph.cells[er_2.cell_id].stale
     assert not k.graph.cells[er_3.cell_id].stale
@@ -106,7 +108,7 @@ def foo():
     update_file(nested_module, "func = lambda : 2")
 
     # wait for the watcher to pick up the change
-    time.sleep(1.5)
+    await asyncio.sleep(1.5)
     assert k.graph.cells[er_1.cell_id].stale
     assert k.graph.cells[er_2.cell_id].stale
     assert not k.graph.cells[er_3.cell_id].stale
@@ -160,7 +162,7 @@ def foo():
     update_file(nested_module, "func = lambda : 2")
 
     # wait for the watcher to pick up the change
-    time.sleep(1.5)
+    await asyncio.sleep(1.5)
     assert k.graph.cells[er_1.cell_id].stale
     assert k.graph.cells[er_2.cell_id].stale
     assert not k.graph.cells[er_3.cell_id].stale
diff --git a/tests/_runtime/test_query_params.py b/tests/_runtime/test_query_params.py
index 2a18d3e460d..35c7aaf39ea 100644
--- a/tests/_runtime/test_query_params.py
+++ b/tests/_runtime/test_query_params.py
@@ -1,8 +1,12 @@
+from __future__ import annotations
+
 import unittest
 from unittest.mock import MagicMock
 
+import pytest
+
 from marimo._messaging.types import Stream
-from marimo._runtime.query_params import QueryParams
+from marimo._runtime.params import CLIArgs, QueryParams
 
 
 class TestQueryParams(unittest.TestCase):
@@ -180,3 +184,50 @@ def test_clear(self) -> None:
             "op": "query-params-clear",
             "data": {},
         }
+
+
+class TestCLIArgs(unittest.TestCase):
+    def setUp(self):
+        self.params = CLIArgs(
+            {"key1": "value1", "key2": ["value2", "value3"]},
+        )
+
+    def test_get(self):
+        assert self.params.get("key1") == "value1"
+        assert self.params.get("key2") == ["value2", "value3"]
+
+    def test_get_all(self):
+        assert self.params.get_all("key1") == ["value1"]
+        assert self.params.get_all("key2") == ["value2", "value3"]
+        assert self.params.get_all("non_existent_key") == []
+
+    def test_contains(self):
+        assert "key1" in self.params
+        assert "non_existent_key" not in self.params
+
+    def test_len(self):
+        assert len(self.params) == 2
+
+    def test_iter(self):
+        keys = [key for key in self.params]
+        assert keys, ["key1", "key2"]
+
+    def test_repr(self):
+        assert (
+            repr(self.params)
+            == "CLIArgs({'key1': 'value1', 'key2': ['value2', 'value3']})"
+        )
+
+    def test_str(self):
+        assert (
+            str(self.params)
+            == "{'key1': 'value1', 'key2': ['value2', 'value3']}"
+        )
+
+    def test_setitem(self):
+        with pytest.raises(TypeError):
+            self.params["key3"] = "value4"
+
+    def test_delete(self):
+        with pytest.raises(TypeError):
+            del self.params["key1"]
diff --git a/tests/_runtime/test_runtime.py b/tests/_runtime/test_runtime.py
index b5a0e479982..60ecebdeec9 100644
--- a/tests/_runtime/test_runtime.py
+++ b/tests/_runtime/test_runtime.py
@@ -880,7 +880,9 @@ def test_sys_path_updated(tmp_path: pathlib.Path) -> None:
             stdin=None,
             cell_configs={},
             user_config=DEFAULT_CONFIG,
-            app_metadata=AppMetadata(query_params={}, filename=filename),
+            app_metadata=AppMetadata(
+                query_params={}, filename=filename, cli_args={}
+            ),
             execute_stale_cells_callback=lambda: None,
         )
         assert str(tmp_path) in sys.path
diff --git a/tests/_server/mocks.py b/tests/_server/mocks.py
index 15e070c4e6e..2fdb79c176b 100644
--- a/tests/_server/mocks.py
+++ b/tests/_server/mocks.py
@@ -53,6 +53,7 @@ def __():
         include_code=True,
         lsp_server=lsp_server,
         user_config_manager=UserConfigManager(),
+        cli_args={},
     )
     sm.server_token = "fake-token"
     return sm
diff --git a/tests/_server/test_session_manager.py b/tests/_server/test_session_manager.py
index 19ee7e7744a..ab7f6d01d1a 100644
--- a/tests/_server/test_session_manager.py
+++ b/tests/_server/test_session_manager.py
@@ -32,6 +32,7 @@ def session_manager():
         include_code=True,
         lsp_server=MagicMock(spec=LspServer),
         user_config_manager=UserConfigManager(),
+        cli_args={},
     )
 
 
diff --git a/tests/_server/test_sessions.py b/tests/_server/test_sessions.py
index cfc367543d3..1d762891f32 100644
--- a/tests/_server/test_sessions.py
+++ b/tests/_server/test_sessions.py
@@ -1,4 +1,6 @@
 # Copyright 2024 Marimo. All rights reserved.
+from __future__ import annotations
+
 import queue
 import sys
 from multiprocessing.queues import Queue as MPQueue
@@ -17,8 +19,7 @@
 
 
 app_metadata = AppMetadata(
-    query_params={"some_param": "some_value"},
-    filename="test.py",
+    query_params={"some_param": "some_value"}, filename="test.py", cli_args={}
 )
 
 
@@ -137,7 +138,7 @@ def test_session_disconnect_reconnect() -> None:
         queue_manager,
         SessionMode.RUN,
         {},
-        AppMetadata(query_params={}),
+        AppMetadata(query_params={}, cli_args={}),
         UserConfigManager(),
         virtual_files_supported=True,
     )
diff --git a/tests/conftest.py b/tests/conftest.py
index f3ac799a49b..3590b88770f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -93,8 +93,7 @@ def __post_init__(self) -> None:
             cell_configs={},
             user_config=DEFAULT_CONFIG,
             app_metadata=AppMetadata(
-                query_params={},
-                filename=None,
+                query_params={}, filename=None, cli_args={}
             ),
             debugger_override=MarimoPdb(stdout=self.stdout, stdin=self.stdin),
             execute_stale_cells_callback=lambda: None,


Code review comments:
diff_hunk: @@ -215,6 +215,28 @@ def query_params() -> QueryParams:
     return get_context().kernel.query_params
 
 
+@mddoc
+def cli_args() -> CLIArgs:
+    """Get the command line arguments of a marimo app.
path: marimo/_runtime/runtime.py
text: @user1:
```suggestion
    """Get the command line arguments of a marimo notebook.
```

diff_hunk: @@ -215,6 +215,28 @@ def query_params() -> QueryParams:
     return get_context().kernel.query_params
 
 
+@mddoc
+def cli_args() -> CLIArgs:
+    """Get the command line arguments of a marimo app.
+
+    **Examples**:
+
+    ```python3
+    # Access the command line arguments
+    size = mo.cli_args().get("size") or 100
+
+    for i in range(size):
+        print(i)
+    ```
+
+    **Returns**:
+
+    - A dictionary containing the command line arguments.
+      This dictionary is read-only and cannot be mutated.
+    """
+    return get_context().kernel.cli_args
path: marimo/_runtime/runtime.py
text: @user1:
This will throw when run as a script.

Catch `ContextNotInitializedError` and warn that CLI args not yet supported when running as a script?