cli: Add --dev-container flag to open project in dev container (#51175)

Toni Alatalo and Claude Opus 4.6 created

## Before you mark this PR as ready for review, make sure that you have:

- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

## Summary

Adds a `--dev-container` CLI flag that automatically triggers "Reopen in
Dev Container" when a `.devcontainer/` configuration is found in the
project directory.

```sh
zed --dev-container /path/to/project
```

## Motivation

This enables fully scripted dev container workflows — for example,
creating a git worktree and immediately opening it in a dev container
without any manual UI interaction:

```sh
git worktree add ../feature-branch
zed --dev-container ../feature-branch
```

The dev container modal fires automatically once the workspace finishes
initializing, so the environment is ready by the time you look at the
window. This is useful for automation scripts that prepare environments
and kick off agent runs for tasks like bug report triage.

Here's an [example
script](https://github.com/antont/todo-rs-ts/blob/main/scripts/devcontainer-new.sh)
that creates a worktree and opens it as a dev container in one step.

Related: #48682 requests a `devcontainer://` protocol for connecting to
already-running dev containers — a complementary but different use case.
This PR covers the "open project and trigger dev container setup" path.

## How it works

- The `--dev-container` flag flows through the CLI IPC protocol to the
workspace as an `open_in_dev_container` option.
- On the first worktree scan completion, if devcontainer configs are
detected, the dev container modal opens automatically.
- If no `.devcontainer/` config is found, the flag is cleared and a
warning is logged.

## Notable changes

- **`Workspace::worktree_scans_complete`** promoted from `#[cfg(test)]`
to production. It was only test-gated because it had no production
callers — it's a pure read-only future with no side effects.
- **`suggest_on_worktree_updated`** now takes `&mut Workspace` to read
and clear the CLI flag.
- Extracted **`open_dev_container_modal`** helper shared between the CLI
code path and the suggest notification.

## Test plan

- [x] `cargo test -p zed open_listener` — includes
`test_dev_container_flag_opens_modal` and
`test_dev_container_flag_cleared_without_config`
- [x] `cargo test -p recent_projects` — existing suggest tests still
pass
- [x] Manual: `cargo run -- --dev-container
/path/to/project-with-devcontainer` opens the modal

Release Notes:

- Added `--dev-container` CLI flag to automatically open a project in a
dev container when `.devcontainer/` configuration is present.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Change summary

crates/cli/src/cli.rs                               |   1 
crates/cli/src/main.rs                              |   7 
crates/recent_projects/src/dev_container_suggest.rs |  35 +++
crates/recent_projects/src/recent_projects.rs       |   3 
crates/workspace/src/workspace.rs                   |  32 +++
crates/zed/src/main.rs                              |  14 +
crates/zed/src/zed.rs                               |   1 
crates/zed/src/zed/open_listener.rs                 | 127 +++++++++++++++
crates/zed/src/zed/windows_only_instance.rs         |   1 
9 files changed, 215 insertions(+), 6 deletions(-)

Detailed changes

crates/cli/src/cli.rs 🔗

@@ -21,6 +21,7 @@ pub enum CliRequest {
         reuse: bool,
         env: Option<HashMap<String, String>>,
         user_data_dir: Option<String>,
+        dev_container: bool,
     },
 }
 

crates/cli/src/main.rs 🔗

@@ -118,6 +118,12 @@ struct Args {
     /// Will attempt to give the correct command to run
     #[arg(long)]
     system_specs: bool,
+    /// Open the project in a dev container.
+    ///
+    /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/`
+    /// configuration is found in the project directory.
+    #[arg(long)]
+    dev_container: bool,
     /// Pairs of file paths to diff. Can be specified multiple times.
     /// When directories are provided, recurses into them and shows all changed files in a single multi-diff view.
     #[arg(long, action = clap::ArgAction::Append, num_args = 2, value_names = ["OLD_PATH", "NEW_PATH"])]
@@ -670,6 +676,7 @@ fn main() -> Result<()> {
                     reuse: args.reuse,
                     env,
                     user_data_dir: user_data_dir_for_thread,
+                    dev_container: args.dev_container,
                 })?;
 
                 while let Ok(response) = rx.recv() {

crates/recent_projects/src/dev_container_suggest.rs 🔗

@@ -30,17 +30,20 @@ fn project_devcontainer_key(project_path: &str) -> String {
 }
 
 pub fn suggest_on_worktree_updated(
+    workspace: &mut Workspace,
     worktree_id: WorktreeId,
     updated_entries: &UpdatedEntriesSet,
     project: &gpui::Entity<Project>,
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
+    let cli_auto_open = workspace.open_in_dev_container();
+
     let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| {
         path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path()
     });
 
-    if !devcontainer_updated {
+    if !devcontainer_updated && !cli_auto_open {
         return;
     }
 
@@ -54,7 +57,35 @@ pub fn suggest_on_worktree_updated(
         return;
     }
 
-    if find_configs_in_snapshot(worktree).is_empty() {
+    let has_configs = !find_configs_in_snapshot(worktree).is_empty();
+
+    if cli_auto_open {
+        workspace.set_open_in_dev_container(false);
+        let task = cx.spawn_in(window, async move |workspace, cx| {
+            let scans_complete =
+                workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?;
+            scans_complete.await;
+
+            workspace.update_in(cx, |workspace, window, cx| {
+                let has_configs = workspace
+                    .project()
+                    .read(cx)
+                    .worktrees(cx)
+                    .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty());
+                if has_configs {
+                    cx.on_next_frame(window, move |_workspace, window, cx| {
+                        window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
+                    });
+                } else {
+                    log::warn!("--dev-container: no devcontainer configuration found in project");
+                }
+            })
+        });
+        workspace.set_dev_container_task(task);
+        return;
+    }
+
+    if !has_configs {
         return;
     }
 

crates/recent_projects/src/recent_projects.rs 🔗

@@ -475,11 +475,12 @@ pub fn init(cx: &mut App) {
             cx.subscribe_in(
                 workspace.project(),
                 window,
-                move |_, project, event, window, cx| {
+                move |workspace, project, event, window, cx| {
                     if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
                         event
                     {
                         dev_container_suggest::suggest_on_worktree_updated(
+                            workspace,
                             *worktree_id,
                             updated_entries,
                             project,

crates/workspace/src/workspace.rs 🔗

@@ -1344,6 +1344,8 @@ pub struct Workspace {
     scheduled_tasks: Vec<Task<()>>,
     last_open_dock_positions: Vec<DockPosition>,
     removing: bool,
+    open_in_dev_container: bool,
+    _dev_container_task: Option<Task<Result<()>>>,
     _panels_task: Option<Task<Result<()>>>,
     sidebar_focus_handle: Option<FocusHandle>,
     multi_workspace: Option<WeakEntity<MultiWorkspace>>,
@@ -1778,6 +1780,8 @@ impl Workspace {
             removing: false,
             sidebar_focus_handle: None,
             multi_workspace,
+            open_in_dev_container: false,
+            _dev_container_task: None,
         }
     }
 
@@ -2800,6 +2804,18 @@ impl Workspace {
         self.debugger_provider = Some(Arc::new(provider));
     }
 
+    pub fn set_open_in_dev_container(&mut self, value: bool) {
+        self.open_in_dev_container = value;
+    }
+
+    pub fn open_in_dev_container(&self) -> bool {
+        self.open_in_dev_container
+    }
+
+    pub fn set_dev_container_task(&mut self, task: Task<Result<()>>) {
+        self._dev_container_task = Some(task);
+    }
+
     pub fn debugger_provider(&self) -> Option<Arc<dyn DebuggerProvider>> {
         self.debugger_provider.clone()
     }
@@ -3026,7 +3042,6 @@ impl Workspace {
         self.project.read(cx).visible_worktrees(cx)
     }
 
-    #[cfg(any(test, feature = "test-support"))]
     pub fn worktree_scans_complete(&self, cx: &App) -> impl Future<Output = ()> + 'static + use<> {
         let futures = self
             .worktrees(cx)
@@ -9214,6 +9229,7 @@ pub struct OpenOptions {
     pub requesting_window: Option<WindowHandle<MultiWorkspace>>,
     pub open_mode: OpenMode,
     pub env: Option<HashMap<String, String>>,
+    pub open_in_dev_container: bool,
 }
 
 /// The result of opening a workspace via [`open_paths`], [`Workspace::new_local`],
@@ -9393,12 +9409,17 @@ pub fn open_paths(
             }
         }
 
+        let open_in_dev_container = open_options.open_in_dev_container;
+
         let result = if let Some((existing, target_workspace)) = existing {
             let open_task = existing
                 .update(cx, |multi_workspace, window, cx| {
                     window.activate_window();
                     multi_workspace.activate(target_workspace.clone(), window, cx);
                     target_workspace.update(cx, |workspace, cx| {
+                        if open_in_dev_container {
+                            workspace.set_open_in_dev_container(true);
+                        }
                         workspace.open_paths(
                             abs_paths,
                             OpenOptions {
@@ -9426,6 +9447,13 @@ pub fn open_paths(
 
             Ok(OpenResult { window: existing, workspace: target_workspace, opened_items: open_task })
         } else {
+            let init = if open_in_dev_container {
+                Some(Box::new(|workspace: &mut Workspace, _window: &mut Window, _cx: &mut Context<Workspace>| {
+                    workspace.set_open_in_dev_container(true);
+                }) as Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>)
+            } else {
+                None
+            };
             let result = cx
                 .update(move |cx| {
                     Workspace::new_local(
@@ -9433,7 +9461,7 @@ pub fn open_paths(
                         app_state.clone(),
                         open_options.requesting_window,
                         open_options.env,
-                        None,
+                        init,
                         open_options.open_mode,
                         cx,
                     )

crates/zed/src/main.rs 🔗

@@ -857,6 +857,7 @@ fn main() {
                 diff_paths,
                 wsl,
                 diff_all: diff_all_mode,
+                dev_container: args.dev_container,
             })
         }
 
@@ -1208,6 +1209,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
     }
 
     let mut task = None;
+    let dev_container = request.dev_container;
     if !request.open_paths.is_empty() || !request.diff_paths.is_empty() {
         let app_state = app_state.clone();
         task = Some(cx.spawn(async move |cx| {
@@ -1218,7 +1220,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 &request.diff_paths,
                 request.diff_all,
                 app_state,
-                workspace::OpenOptions::default(),
+                workspace::OpenOptions {
+                    open_in_dev_container: dev_container,
+                    ..Default::default()
+                },
                 cx,
             )
             .await?;
@@ -1636,6 +1641,13 @@ struct Args {
     #[arg(long, value_name = "USER@DISTRO")]
     wsl: Option<String>,
 
+    /// Open the project in a dev container.
+    ///
+    /// Automatically triggers "Reopen in Dev Container" if a `.devcontainer/`
+    /// configuration is found in the project directory.
+    #[arg(long)]
+    dev_container: bool,
+
     /// Instructs zed to run as a dev server on this machine. (not implemented)
     #[arg(long)]
     dev_server_token: Option<String>,

crates/zed/src/zed.rs 🔗

@@ -5167,6 +5167,7 @@ mod tests {
             app_state.languages.add(markdown_lang());
 
             gpui_tokio::init(cx);
+            AppState::set_global(app_state.clone(), cx);
             theme_settings::init(theme::LoadThemes::JustBase, cx);
             audio::init(cx);
             channel::init(&app_state.client, app_state.user_store.clone(), cx);

crates/zed/src/zed/open_listener.rs 🔗

@@ -37,6 +37,7 @@ pub struct OpenRequest {
     pub open_paths: Vec<String>,
     pub diff_paths: Vec<[String; 2]>,
     pub diff_all: bool,
+    pub dev_container: bool,
     pub open_channel_notes: Vec<(u64, Option<String>)>,
     pub join_channel: Option<u64>,
     pub remote_connection: Option<RemoteConnectionOptions>,
@@ -78,6 +79,7 @@ impl OpenRequest {
 
         this.diff_paths = request.diff_paths;
         this.diff_all = request.diff_all;
+        this.dev_container = request.dev_container;
         if let Some(wsl) = request.wsl {
             let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
                 if user.is_empty() {
@@ -256,6 +258,7 @@ pub struct RawOpenRequest {
     pub urls: Vec<String>,
     pub diff_paths: Vec<[String; 2]>,
     pub diff_all: bool,
+    pub dev_container: bool,
     pub wsl: Option<String>,
 }
 
@@ -413,6 +416,7 @@ pub async fn handle_cli_connection(
                 reuse,
                 env,
                 user_data_dir: _,
+                dev_container,
             } => {
                 if !urls.is_empty() {
                     cx.update(|cx| {
@@ -421,6 +425,7 @@ pub async fn handle_cli_connection(
                                 urls,
                                 diff_paths,
                                 diff_all,
+                                dev_container,
                                 wsl,
                             },
                             cx,
@@ -450,6 +455,7 @@ pub async fn handle_cli_connection(
                     reuse,
                     &responses,
                     wait,
+                    dev_container,
                     app_state.clone(),
                     env,
                     cx,
@@ -471,6 +477,7 @@ async fn open_workspaces(
     reuse: bool,
     responses: &IpcSender<CliResponse>,
     wait: bool,
+    dev_container: bool,
     app_state: Arc<AppState>,
     env: Option<collections::HashMap<String, String>>,
     cx: &mut AsyncApp,
@@ -532,6 +539,7 @@ async fn open_workspaces(
             requesting_window: replace_window,
             wait,
             env: env.clone(),
+            open_in_dev_container: dev_container,
             ..Default::default()
         };
 
@@ -1545,4 +1553,123 @@ mod tests {
             })
             .unwrap();
     }
+
+    #[gpui::test]
+    async fn test_dev_container_flag_opens_modal(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(|cx| recent_projects::init(cx));
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/project"),
+                json!({
+                    ".devcontainer": {
+                        "devcontainer.json": "{}"
+                    },
+                    "src": {
+                        "main.rs": "fn main() {}"
+                    }
+                }),
+            )
+            .await;
+
+        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+        let errored = cx
+            .spawn({
+                let app_state = app_state.clone();
+                |mut cx| async move {
+                    open_local_workspace(
+                        vec![path!("/project").to_owned()],
+                        vec![],
+                        false,
+                        workspace::OpenOptions {
+                            open_in_dev_container: true,
+                            ..Default::default()
+                        },
+                        &response_tx,
+                        &app_state,
+                        &mut cx,
+                    )
+                    .await
+                }
+            })
+            .await;
+
+        assert!(!errored);
+
+        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                let flag = multi_workspace.workspace().read(cx).open_in_dev_container();
+                assert!(
+                    !flag,
+                    "open_in_dev_container flag should be consumed by suggest_on_worktree_updated"
+                );
+            })
+            .unwrap();
+    }
+
+    #[gpui::test]
+    async fn test_dev_container_flag_cleared_without_config(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(|cx| recent_projects::init(cx));
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/project"),
+                json!({
+                    "src": {
+                        "main.rs": "fn main() {}"
+                    }
+                }),
+            )
+            .await;
+
+        let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
+        let errored = cx
+            .spawn({
+                let app_state = app_state.clone();
+                |mut cx| async move {
+                    open_local_workspace(
+                        vec![path!("/project").to_owned()],
+                        vec![],
+                        false,
+                        workspace::OpenOptions {
+                            open_in_dev_container: true,
+                            ..Default::default()
+                        },
+                        &response_tx,
+                        &app_state,
+                        &mut cx,
+                    )
+                    .await
+                }
+            })
+            .await;
+
+        assert!(!errored);
+
+        // Let any pending worktree scan events and updates settle.
+        cx.run_until_parked();
+
+        // With no .devcontainer config, the flag should be cleared once the
+        // worktree scan completes, rather than persisting on the workspace.
+        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                let flag = multi_workspace
+                    .workspace()
+                    .read(cx)
+                    .open_in_dev_container();
+                assert!(
+                    !flag,
+                    "open_in_dev_container flag should be cleared when no devcontainer config exists"
+                );
+            })
+            .unwrap();
+    }
 }

crates/zed/src/zed/windows_only_instance.rs 🔗

@@ -162,6 +162,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
             reuse: false,
             env: None,
             user_data_dir: args.user_data_dir.clone(),
+            dev_container: args.dev_container,
         }
     };