Skip to content
Merged
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
77 changes: 75 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,77 @@ If the folder has no `.devcontainer/devcontainer.json`, falls back to plain `cod
Open `<path>` (default: current directory) in VS Code via the configured devcontainer.
Exit code is forwarded from the spawned editor.

### `dcode shell`

Open an interactive shell inside the project's running devcontainer.

```bash
dcode shell # current directory
dcode shell ./my-project # specific path
dcode shell --shell zsh # explicit shell executable (overrides settings)
```

Shell selection priority (highest first):

1. `--shell` CLI flag (literal executable; no argument parsing)
2. Workspace `<workspace>/.vscode/settings.json`:
`terminal.integrated.defaultProfile.linux` plus the matching
`terminal.integrated.profiles.linux` entry
3. `devcontainer.json` `customizations.vscode.settings` with the same keys
4. Host user-level VS Code settings, such as `~/Library/Application Support/Code/User/settings.json`
on macOS, `~/.config/Code/User/settings.json` on Linux, or Windows-side
settings via the WSL bridge
5. Container login shell from `getent passwd <user>` (`nologin` and `false` are rejected)
6. Fallback: `/bin/bash`, then `/bin/sh`

`dcode shell` always reads the `.linux` terminal settings because devcontainers
run Linux, even on macOS and WSL hosts. Profile `args` and `env` are honored;
if a profile `path` is a list, the first entry is used. `${...}` substitution in
profile values is not resolved in this version, so those values are passed
through verbatim with a warning.

SSH agent forwarding works automatically when VS Code is open and connected to
the devcontainer. `dcode shell` detects the VS Code relay socket at
`/tmp/vscode-ssh-auth-*.sock` and sets `SSH_AUTH_SOCK` on `docker exec`. If no
socket is found, it prints a hint to open the project in VS Code first.

The shell runs as `remoteUser` from `devcontainer.json` when set, then
`containerUser`, otherwise the container image's `USER` applies.

The working directory matches the URI logic: `<workspaceFolder>/<worktree-relative-path>`
for worktrees, otherwise `<workspaceFolder>`. The path is probed with `test -d`;
if it does not exist, `dcode shell` falls back to the base `<workspaceFolder>`
with a warning, or omits `-w` entirely if that is missing too.

Limitations:

- **GPG agent forwarding is not yet supported.** Commit signing inside the shell
will not work unless you've configured your own GPG forwarding via
`containerEnv` and a bind mount.
- **`remoteEnv` is not applied.** The environment may differ from VS Code's
integrated terminal; a warning is printed when `remoteEnv` is present in
`devcontainer.json`.
- **Variable substitution** (`${env:VAR}`, `${localEnv:VAR}`) in terminal
profile values is not resolved.
- **Devcontainer config inheritance** (`extends`, image-label metadata, Docker
Compose service `user`) is not merged; only the raw `devcontainer.json` file
is read. For complex setups, shell selection may differ from VS Code's
resolved view.
- **Requires an interactive terminal.** `dcode shell` exits with an error when
stdin or stdout is not a TTY, such as in piped or scripted contexts.

Common errors:

- No `devcontainer.json`: exits non-zero and points you at `dcode doctor`.
- Container not running: no matching devcontainer was found; open the project in
VS Code first.
- Container stopped: run `dcode <path>` to start it.
- Multiple matching containers: clean up the duplicate containers listed in the
error.
- Docker not available: install/start Docker or Docker Desktop and try again.

To open a folder literally named `shell`, run `dcode ./shell`.

### `dcode doctor [path]`

Diagnose the local environment for dcode and print a "what would `dcode <path>` do here?"
Expand Down Expand Up @@ -71,10 +142,12 @@ Exit codes:

### Naming-collision workaround

`doctor` and `update` are subcommands, so `dcode doctor` and `dcode update` always
invoke them. To open a folder literally named `doctor` or `update`, prefix the path:
`shell`, `doctor`, and `update` are subcommands, so `dcode shell`, `dcode doctor`,
and `dcode update` always invoke them. To open a folder literally named `shell`,
`doctor`, or `update`, prefix the path:

```bash
dcode ./shell
dcode ./doctor
dcode "$(pwd)/update"
```
Expand Down
54 changes: 50 additions & 4 deletions src/dcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from dcode.doctor import run_doctor
from dcode.update import run_update, run_update_check

_SUBCOMMANDS = ("doctor", "update")
_SUBCOMMANDS = ("doctor", "update", "shell")


def _build_parser() -> argparse.ArgumentParser:
Expand All @@ -19,9 +19,10 @@ def _build_parser() -> argparse.ArgumentParser:
description=(
"Open a folder in a VS Code devcontainer.\n"
"\n"
"`dcode doctor` and `dcode update` always run their respective subcommands. "
"To open a folder literally named 'doctor' or 'update', "
"run `dcode ./doctor` or `dcode ./update`."
"`dcode doctor`, `dcode update`, and `dcode shell` always run their "
"respective subcommands. To open a folder literally named "
"'doctor', 'update', or 'shell', run `dcode ./doctor`, "
"`dcode ./update`, or `dcode ./shell`."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
Expand Down Expand Up @@ -57,6 +58,37 @@ def _build_parser() -> argparse.ArgumentParser:
help="check for an available update without installing it",
)

p_shell = subparsers.add_parser(
"shell",
help="Open a shell in the project's running devcontainer",
description=(
"Open an interactive shell inside the running devcontainer for "
"the project at `path`. Mirrors VS Code's integrated terminal: "
"respects terminal profile settings (workspace > devcontainer > "
"user), forwards the SSH agent socket when available, runs as "
"`remoteUser`/`containerUser` from devcontainer.json. Requires "
"an interactive terminal. To open a folder literally named "
"'shell', use `dcode ./shell`."
),
)
p_shell.add_argument(
"shell_path",
nargs="?",
default=".",
metavar="path",
help="project folder (default: current directory)",
)
p_shell.add_argument(
"--shell",
default=None,
dest="shell_override",
metavar="EXECUTABLE",
help=(
"literal shell executable to use (overrides VS Code settings); "
"no shell-style argument splitting"
),
)

parser.add_argument(
"path",
nargs="?",
Expand Down Expand Up @@ -85,6 +117,7 @@ def _looks_like_subcommand(argv: list[str]) -> bool:

def main() -> None:
argv = sys.argv[1:]
parser: argparse.ArgumentParser | None = None
if _looks_like_subcommand(argv):
parser = _build_parser()
args = parser.parse_args(argv)
Expand All @@ -105,4 +138,17 @@ def main() -> None:
sys.exit(run_update_check())
sys.exit(run_update())

if args.command == "shell":
shell_override = args.shell_override
if shell_override is not None and (
shell_override.strip() != shell_override or any(c.isspace() for c in shell_override)
):
assert parser is not None
parser.error(
"--shell must be a single executable path or name (no arguments); "
"use VS Code terminal profile args for that"
)
from dcode.shell import run_shell
sys.exit(run_shell(args.shell_path, insiders=args.insiders, shell_override=shell_override))

run_dcode(args.path, insiders=args.insiders)
Loading
Loading