Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 29 additions & 17 deletions modal/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,12 @@ def shell(
```
modal shell sb-abc123xyz
```

Launch a shell to explore an Image by ID:

```
modal shell im-abc123xyz
```
"""
env = ensure_env(env)

Expand All @@ -624,7 +630,29 @@ def shell(

app = App("modal shell")

if ref is not None:
if ref is None or ref.startswith("im-"):
if ref is None:
modal_image = Image.from_registry(image, add_python=add_python) if image else None
else:
if image is not None:
raise ClickException("image is not supported when running `modal shell im-*`")
modal_image = Image.from_id(ref)

volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
secrets = [] if secret is None else [Secret.from_name(s) for s in secret]
start_shell = partial(
interactive_shell,
image=modal_image,
cpu=cpu,
memory=memory,
gpu=gpu,
cloud=cloud,
volumes=volumes,
secrets=secrets,
region=region.split(",") if region else [],
pty=pty,
Comment on lines +641 to +653
Copy link
Contributor

@mwaskom mwaskom Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing that bothers me about this (and where we got stuck in @ehdr's previous effort, motivating his attempt at a refactor) is that it makes it harder to explain the modal shell parameterization. Currently we have a (relatively) understandable heuristic that you pass REF or you pass a specification in the flags. This adds complexity because you need to say "If not passing REF, or if REF is an image ID".

OTOH it's not actively in conflict like it is with a task / sandbox ID (Function reference is more ambiguous since you could define "override" semantics for the additional flags, we just don't currently do that) and I can see how it might be useful to drop into a specific Image with a GPU etc...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd be 👍 if we can plan to implement "override" semantics in the function reference case, so that the additional flags work where they "could" and it's not too hard to explain why you can't, e.g., start a shell into an existing container and add a GPU.

)
else:
# `modal shell` with a sandbox ID gets the task_id, that's then handled by the `ta-*` flow below.
if ref.startswith("sb-") and len(ref[3:]) > 0 and ref[3:].isalnum():
from ..sandbox import Sandbox
Expand Down Expand Up @@ -688,22 +716,6 @@ def shell(
pty=pty,
proxy=function_spec.proxy,
)
else:
modal_image = Image.from_registry(image, add_python=add_python) if image else None
volumes = {} if volume is None else {f"/mnt/{vol}": Volume.from_name(vol) for vol in volume}
secrets = [] if secret is None else [Secret.from_name(s) for s in secret]
start_shell = partial(
interactive_shell,
image=modal_image,
cpu=cpu,
memory=memory,
gpu=gpu,
cloud=cloud,
volumes=volumes,
secrets=secrets,
region=region.split(",") if region else [],
pty=pty,
)

# NB: invoking under bash makes --cmd a lot more flexible.
cmds = shlex.split(f'/bin/bash -c "{cmd}"')
Expand Down
17 changes: 17 additions & 0 deletions test/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,23 @@ def test_shell_preserve_token(servicer, set_env_client, mock_shell_pty, monkeypa
assert captured_out == [(1, shell_prompt), (1, expected_output)]


@skip_windows("modal shell is not supported on Windows.")
def test_shell_modal_image(servicer, set_env_client, mock_shell_pty):
image_id = "im-123abcxyz"
_run(["shell", image_id])
sandbox_def = servicer.sandbox_defs[0]
assert sandbox_def.image_id == image_id


@skip_windows("modal shell is not supported on Windows.")
def test_shell_modal_image_error(servicer):
_run(
["shell", "im-hello", "--image", "some-image"],
expected_exit_code=1,
expected_stderr="image is not supported",
)


def test_shell_unsuported_cmds_fails_on_windows(servicer, set_env_client, mock_shell_pty):
expected_exit_code = 1 if platform.system() == "Windows" else 0
res = _run(["shell"], expected_exit_code=expected_exit_code)
Expand Down
5 changes: 5 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -1614,6 +1614,11 @@ async def ImageGetOrCreate(self, stream):
)
)

async def ImageFromId(self, stream):
request: api_pb2.ImageFromIdRequest = await stream.recv_message()
image_id = request.image_id
await stream.send_message(api_pb2.ImageFromIdResponse(image_id=image_id))

async def ImageJoinStreaming(self, stream):
req = await stream.recv_message()

Expand Down
Loading