From 5fa29c2d7f956584066d0a9ba4c11d585b1dba6e Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Sun, 26 Jul 2026 09:10:51 +0900 Subject: [PATCH 1/2] fix(deps): run package managers through mise exec A worktree is created by a separate process, so the shell hook that puts mise-managed tools on PATH never fires there. In a project whose toolchain is pinned by mise, `worktree create` / `worktree checkout ` died with `dotnet: command not found` while installing dependencies. - install_deps prefixes commands with `mise exec --` when a mise config applies to the worktree, and runs them directly otherwise - find_mise_configs walks from the install directory up to the task directory, so a multi-repo config living in the task dir (symlinked from the project root) is found from a sub-repo; it never walks past that boundary into unrelated configs - configs are passed to `mise trust` first: a worktree is a brand new absolute path, so a config trusted in the project root is untrusted there and `mise exec` aborts instead of prompting in this non-interactive context. `.tool-versions` carries no executable content and is not trusted - WORKTREE_NO_MISE=1 opts out of mise entirely - run test/deps.bats in CI, which was never wired up Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 20 ++++ CLAUDE.md | 11 ++ README.md | 15 +++ lib/cmd_checkout.sh | 2 +- lib/cmd_create.sh | 2 +- lib/deps.sh | 125 +++++++++++++++++++++-- skills/worktree-checkout/SKILL.md | 5 + skills/worktree-create/SKILL.md | 2 +- test/deps.bats | 160 +++++++++++++++++++++++++++++- 9 files changed, 332 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5956285..ccb1b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,26 @@ permissions: issues: read jobs: + test-deps: + # Unit tests for lib/deps.sh. No network and no clone, so they run on both + # platforms cheaply. + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: true + - name: Install modern Bash (macOS) + if: runner.os == 'macOS' + run: | + brew install bash + echo "$(brew --prefix)/bin" >> "$GITHUB_PATH" + - name: Run deps unit tests + run: ./test/bats/bin/bats test/deps.bats + test-single-repo: # Run on both Linux and macOS: macOS ships BSD userland (e.g. readlink # without -f), so it guards against Linux-only assumptions in the scripts. diff --git a/CLAUDE.md b/CLAUDE.md index 4bdff4b..22b0a07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,17 @@ create / checkout 時に Worktree Context(タスク名、作業ディレクト 実装は `lib/deps.sh` の `detect_deps_types` (検出) と `install_deps` (実行) に集約されている。 +### mise 管理ツールチェーンの解決 (`mise exec`) + +worktree は別プロセスが作るディレクトリなので、`mise activate` のシェルフックが発火せず **mise 管理のツール (dotnet / node / php 等) が PATH に載らない**。そのまま `dotnet restore` を呼ぶと `command not found` で install が失敗していた。 + +- worktree に効く mise 設定ファイルが見つかった場合、パッケージマネージャは `mise exec -- ` 経由で実行する (`run_deps_cmd` の第 1 引数以降にプレフィックスとして差し込む)。見つからない、あるいは `mise` が PATH に無い場合は従来どおり直接実行。 +- 実行前に **`mise trust ` を実行する**。worktree は新しい絶対パスなのでプロジェクトルートで信頼済みの設定でも untrusted 扱いになり、`mise exec` は非対話環境ではプロンプトを出さず **エラー終了する** ため。`[env]` やテンプレートを含む設定で必須。`.tool-versions` はコード実行要素が無いので trust 対象外。 +- **設定探索は `find_mise_configs [boundary]`** が担当し、`` から `` (= task ディレクトリ) まで遡って探す。マルチリポでは mise.toml が task ディレクトリ側 (プロジェクトルートからの symlink) にあり、install はサブリポ内で走るため。boundary を超えて `/` まで遡らないので、ホーム配下の無関係な設定を拾わない。 +- `install_deps [config-root]` の第 2 引数が boundary。`cmd_create.sh` / `cmd_checkout.sh` は `$task_dir` を渡す。 +- **`WORKTREE_NO_MISE=1` で mise 連携全体を無効化**できる (常に PATH から直接実行)。 +- セキュリティ上の注意: `worktree checkout ` では **PR ブランチ側の mise 設定を trust する**ことになる。`npm install` / `composer install` 自体が既に任意コード実行なので相対的なリスク増は小さいが、信頼できない PR を扱う場合は `WORKTREE_NO_MISE=1` か `--no-install` を使う。 + ## mise 設定の引き継ぎ `worktree create` は、ソース側に `mise.toml` / `mise.local.toml` が存在する場合、それらを新しい worktree にコピーする。gitignored なローカル上書き (`mise.local.toml` など) でも、mise のバージョン固定を引き継げる。 diff --git a/README.md b/README.md index 6c56d64..a959b02 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,21 @@ mutually exclusive and resolved with priority `npm > pnpm > yarn`. Skip with `--no-install`. +### mise-managed Toolchains + +If a mise config (`mise.toml`, `mise.local.toml`, `.tool-versions`, ...) applies to the +worktree, the install commands run through `mise exec --`. A worktree is created by a +separate process, so the shell hook that puts mise-managed tools on `PATH` never fires +there and commands such as `dotnet restore` would otherwise fail with +`command not found`. + +The config is also passed to `mise trust` first: a worktree is a brand new absolute path, +so a config trusted in the project root is untrusted there, and `mise exec` aborts on an +untrusted config instead of prompting. Note that this trusts the config as checked out — +including on a contributor's branch via `worktree checkout `. Set +`WORKTREE_NO_MISE=1` to skip mise entirely and run the package managers straight from +`PATH`. + ## mise Version Inheritance If `mise.toml` or `mise.local.toml` exists in the source (project root for single-repo, each sub-repo root for multi-repo), `worktree create` copies it into the new worktree. This keeps mise-managed tool versions consistent even when the config is gitignored. diff --git a/lib/cmd_checkout.sh b/lib/cmd_checkout.sh index ecc10a1..aebd2ce 100644 --- a/lib/cmd_checkout.sh +++ b/lib/cmd_checkout.sh @@ -362,7 +362,7 @@ cmd_checkout_pr() { echo "" log_info "Installing dependencies..." log_info "${matching_repo_display} (${deps_types_csv}):" - install_deps "$worktree_path" || true + install_deps "$worktree_path" "$task_dir" || true fi else echo "" diff --git a/lib/cmd_create.sh b/lib/cmd_create.sh index 939aa14..af03057 100644 --- a/lib/cmd_create.sh +++ b/lib/cmd_create.sh @@ -283,7 +283,7 @@ cmd_create() { deps_types_csv="$(detect_deps_types "$worktree_path" | paste -sd, -)" if [ -n "$deps_types_csv" ]; then log_info "${repo} (${deps_types_csv}):" - install_deps "$worktree_path" || true + install_deps "$worktree_path" "$task_dir" || true fi done elif [ "$no_install" = true ]; then diff --git a/lib/deps.sh b/lib/deps.sh index de20999..8752405 100644 --- a/lib/deps.sh +++ b/lib/deps.sh @@ -29,12 +29,109 @@ detect_deps_types() { fi } +# File names mise recognises as config files, looked up in every candidate +# directory when deciding whether package managers should run through mise. +MISE_CONFIG_NAMES=( + mise.toml + mise.local.toml + .mise.toml + .mise.local.toml + mise/config.toml + .mise/config.toml + .config/mise.toml + .config/mise/config.toml + .tool-versions +) + +# True when mise integration is available and not disabled. +# Set WORKTREE_NO_MISE=1 to always run package managers straight from PATH. +mise_enabled() { + if [ "${WORKTREE_NO_MISE:-0}" = "1" ]; then + return 1 + fi + command -v mise >/dev/null 2>&1 +} + +# Emit the mise config files that apply to , walking up from to +# (inclusive). defaults to and is ignored when it +# is not an ancestor of ; the walk always stops at "/". +# The boundary keeps the search inside the worktree: in a multi-repo layout the +# config often lives in the task directory (symlinked from the project root) +# rather than in the sub-repo that is being installed. +# Usage: find_mise_configs [boundary] +find_mise_configs() { + local dir boundary + dir="$(cd "$1" 2>/dev/null && pwd)" || return 0 + + boundary="$dir" + if [ -n "${2:-}" ]; then + local candidate + if candidate="$(cd "$2" 2>/dev/null && pwd)"; then + case "${dir}/" in + "${candidate}"/*) boundary="$candidate" ;; + esac + fi + fi + + local current="$dir" + while :; do + local name + for name in "${MISE_CONFIG_NAMES[@]}"; do + if [ -f "${current}/${name}" ]; then + echo "${current}/${name}" + fi + done + if [ "$current" = "$boundary" ] || [ "$current" = "/" ]; then + break + fi + current="$(dirname "$current")" + done + + return 0 +} + +# Mark mise config files as trusted so `mise exec` can parse the ones that may +# execute code (templates, [env], tool options). A worktree is a brand new +# absolute path, so configs trusted in the project root are untrusted here and +# mise would abort instead of prompting in this non-interactive context. +# `.tool-versions` carries no executable content and needs no trust. +# Usage: trust_mise_configs ... +trust_mise_configs() { + local config + for config in "$@"; do + case "$config" in + *.toml) ;; + *) continue ;; + esac + if ! mise trust "$config" >/dev/null 2>&1; then + log_warn " mise trust failed for ${config} (continuing)" + fi + done +} + +# Run a package manager inside , keeping only the last line of its output. +# The entrypoint runs under `set -o pipefail`, so the reported status is the +# package manager's, not tail's. +# Usage: run_deps_cmd [args...] +run_deps_cmd() { + local dir="$1" + shift + (cd "$dir" && "$@" 2>&1 | tail -1) +} + # Install dependencies for every detected package manager. # Returns 0 if at least one install succeeded, 1 otherwise (including no # managers detected). Callers typically swallow the failure with `|| true`. -# Usage: install_deps +# When a mise config applies to the worktree, commands run through +# `mise exec --` so mise-managed toolchains (dotnet, node, php, ...) are on +# PATH: the shell hook that normally sets them up never fires for a directory +# created by another process, which otherwise fails with "command not found". +# bounds the upward search for mise configs (see +# find_mise_configs); pass the task directory. +# Usage: install_deps [config-root] install_deps() { local dir="$1" + local config_root="${2:-$1}" if [ ! -d "$dir" ]; then return 0 @@ -49,13 +146,29 @@ install_deps() { return 1 fi + # Resolve the command prefix: `mise exec --` when mise governs this + # worktree, empty otherwise. + local runner=() + if mise_enabled; then + local mise_configs=() + while IFS= read -r line; do + [ -n "$line" ] && mise_configs+=("$line") + done < <(find_mise_configs "$dir" "$config_root") + + if [ ${#mise_configs[@]} -gt 0 ]; then + trust_mise_configs "${mise_configs[@]}" + runner=(mise exec --) + log_info " Using mise (${mise_configs[0]})" + fi + fi + local installed=false local type for type in "${types[@]}"; do case "$type" in npm) log_info " Running npm install..." - if (cd "$dir" && npm install --no-audit --no-fund 2>&1 | tail -1); then + if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} npm install --no-audit --no-fund; then installed=true else log_warn " npm install failed" @@ -63,7 +176,7 @@ install_deps() { ;; pnpm) log_info " Running pnpm install..." - if (cd "$dir" && pnpm install --frozen-lockfile 2>&1 | tail -1); then + if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} pnpm install --frozen-lockfile; then installed=true else log_warn " pnpm install failed" @@ -71,7 +184,7 @@ install_deps() { ;; yarn) log_info " Running yarn install..." - if (cd "$dir" && yarn install --frozen-lockfile 2>&1 | tail -1); then + if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} yarn install --frozen-lockfile; then installed=true else log_warn " yarn install failed" @@ -79,7 +192,7 @@ install_deps() { ;; composer) log_info " Running composer install..." - if (cd "$dir" && composer install --no-interaction 2>&1 | tail -1); then + if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} composer install --no-interaction; then installed=true else log_warn " composer install failed" @@ -87,7 +200,7 @@ install_deps() { ;; dotnet) log_info " Running dotnet restore..." - if (cd "$dir" && dotnet restore 2>&1 | tail -1); then + if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} dotnet restore; then installed=true else log_warn " dotnet restore failed" diff --git a/skills/worktree-checkout/SKILL.md b/skills/worktree-checkout/SKILL.md index 280ed8f..7cf9d25 100644 --- a/skills/worktree-checkout/SKILL.md +++ b/skills/worktree-checkout/SKILL.md @@ -52,6 +52,11 @@ worktree checkout https://github.com/owner/repo/pull/123 --no-cd | `--no-install` | Skip automatic dependency installation | | `--no-cd` | Do not auto-cd into the new worktree (interactive shells with `worktree shell-init` only) | +> **Note (mise):** When a mise config applies to the new worktree, the dependency +> install runs through `mise exec --` (after `mise trust`) so mise-managed +> toolchains are on `PATH`. This trusts the config as checked out on the PR +> branch; use `WORKTREE_NO_MISE=1` or `--no-install` for untrusted PRs. + > **Note (subagents):** In URL mode, `checkout` auto-cds into the new worktree > only in an interactive shell that has `eval "$(worktree shell-init)"` installed. > From a subagent's `Bash` call there is no directory change, so `--no-cd` has no diff --git a/skills/worktree-create/SKILL.md b/skills/worktree-create/SKILL.md index 1a1dcbd..f35820c 100644 --- a/skills/worktree-create/SKILL.md +++ b/skills/worktree-create/SKILL.md @@ -23,7 +23,7 @@ worktree create $ARGUMENTS 5. Symlinks non-git items into the task directory (multi-repo only) 6. Copies `mise.toml` / `mise.local.toml` into each worktree (inherits mise version pinning even when gitignored) 7. Executes `.worktreerc` `post_create()` hook if present -8. Auto-installs dependencies based on lock files (unless `--no-install`) +8. Auto-installs dependencies based on lock files (unless `--no-install`). When a mise config applies to the worktree it is passed to `mise trust` and the install runs through `mise exec --`, so mise-managed toolchains resolve; `WORKTREE_NO_MISE=1` disables this ## Worktree layout diff --git a/test/deps.bats b/test/deps.bats index 5c34efa..548b7b2 100644 --- a/test/deps.bats +++ b/test/deps.bats @@ -18,7 +18,9 @@ setup() { # the pipe-to-`tail` exit status reflects the real package manager status. set -o pipefail - DEPS_TMP="$(mktemp -d)" + # Normalise the path the same way find_mise_configs does (`cd` + `pwd`), so + # comparisons hold on macOS where mktemp -d returns a symlinked path. + DEPS_TMP="$(cd "$(mktemp -d)" && pwd)" } teardown() { @@ -30,6 +32,7 @@ teardown() { PATH="$SAVED_PATH" unset SAVED_PATH fi + unset WORKTREE_NO_MISE } # --------------------------------------------------------------------------- @@ -134,6 +137,24 @@ EOF PATH="${STUB_BIN}:${PATH}" } +# Shadow mise with a fake that records invocations. `mise exec -- ` runs +# so the stubbed package managers still report success, mirroring the +# real behaviour of putting mise-managed tools on PATH. +# Must be called after setup_stub_clis (it reuses $STUB_BIN / $PATH). +setup_stub_mise() { + cat > "${STUB_BIN}/mise" <> "${DEPS_TMP}/calls.log" +if [ "\$1" = "exec" ]; then + shift + [ "\$1" = "--" ] && shift + exec "\$@" +fi +exit 0 +EOF + chmod +x "${STUB_BIN}/mise" +} + @test "install_deps: composer + npm coexistence runs both" { setup_stub_clis touch "${DEPS_TMP}/package-lock.json" @@ -193,3 +214,140 @@ EOF assert_output --partial "npm install failed" grep -q '^composer install' "${DEPS_TMP}/calls.log" } + +# --------------------------------------------------------------------------- +# find_mise_configs +# --------------------------------------------------------------------------- + +@test "find_mise_configs: no config emits nothing" { + run find_mise_configs "$DEPS_TMP" + assert_success + [ -z "$output" ] +} + +@test "find_mise_configs: finds mise.toml in the directory itself" { + touch "${DEPS_TMP}/mise.toml" + run find_mise_configs "$DEPS_TMP" + assert_success + [ "$output" = "${DEPS_TMP}/mise.toml" ] +} + +@test "find_mise_configs: finds mise.local.toml and .tool-versions" { + touch "${DEPS_TMP}/mise.local.toml" "${DEPS_TMP}/.tool-versions" + run find_mise_configs "$DEPS_TMP" + assert_success + assert_output --partial "${DEPS_TMP}/mise.local.toml" + assert_output --partial "${DEPS_TMP}/.tool-versions" +} + +# Multi-repo layout: the config sits in the task directory (symlinked from the +# project root) while installs run in a sub-repo below it. +@test "find_mise_configs: walks up to the boundary" { + mkdir -p "${DEPS_TMP}/task/sub" + touch "${DEPS_TMP}/task/mise.toml" + run find_mise_configs "${DEPS_TMP}/task/sub" "${DEPS_TMP}/task" + assert_success + [ "$output" = "${DEPS_TMP}/task/mise.toml" ] +} + +@test "find_mise_configs: without a boundary only the directory itself is searched" { + mkdir -p "${DEPS_TMP}/task/sub" + touch "${DEPS_TMP}/task/mise.toml" + run find_mise_configs "${DEPS_TMP}/task/sub" + assert_success + [ -z "$output" ] +} + +@test "find_mise_configs: boundary that is not an ancestor is ignored" { + mkdir -p "${DEPS_TMP}/task/sub" + touch "${DEPS_TMP}/task/mise.toml" + run find_mise_configs "${DEPS_TMP}/task/sub" "/etc" + assert_success + [ -z "$output" ] +} + +@test "find_mise_configs: trailing '.' path component is normalised" { + touch "${DEPS_TMP}/mise.toml" + run find_mise_configs "${DEPS_TMP}/." "$DEPS_TMP" + assert_success + [ "$output" = "${DEPS_TMP}/mise.toml" ] +} + +# --------------------------------------------------------------------------- +# install_deps + mise +# +# Regression: mise-managed toolchains (dotnet, node, ...) are not on PATH in a +# freshly created worktree because the shell hook never fires there, so the +# install used to die with "command not found". +# --------------------------------------------------------------------------- + +@test "install_deps: runs through mise exec and trusts the config when mise.toml exists" { + setup_stub_clis + setup_stub_mise + touch "${DEPS_TMP}/mise.toml" + touch "${DEPS_TMP}/App.csproj" + + run install_deps "$DEPS_TMP" + assert_success + assert_output --partial "Using mise" + + grep -q "^mise trust ${DEPS_TMP}/mise.toml\$" "${DEPS_TMP}/calls.log" + grep -q '^mise exec -- dotnet restore$' "${DEPS_TMP}/calls.log" + # The tool itself still ran, through mise + grep -q '^dotnet restore$' "${DEPS_TMP}/calls.log" +} + +@test "install_deps: uses the mise config found at the boundary" { + setup_stub_clis + setup_stub_mise + mkdir -p "${DEPS_TMP}/task/sub" + touch "${DEPS_TMP}/task/mise.toml" + touch "${DEPS_TMP}/task/sub/package-lock.json" + + run install_deps "${DEPS_TMP}/task/sub" "${DEPS_TMP}/task" + assert_success + + grep -q "^mise trust ${DEPS_TMP}/task/mise.toml\$" "${DEPS_TMP}/calls.log" + grep -q '^mise exec -- npm install' "${DEPS_TMP}/calls.log" +} + +@test "install_deps: .tool-versions enables mise but is not trusted" { + setup_stub_clis + setup_stub_mise + touch "${DEPS_TMP}/.tool-versions" + touch "${DEPS_TMP}/package-lock.json" + + run install_deps "$DEPS_TMP" + assert_success + + grep -q '^mise exec -- npm install' "${DEPS_TMP}/calls.log" + ! grep -q '^mise trust' "${DEPS_TMP}/calls.log" +} + +@test "install_deps: no mise config runs the package manager directly" { + setup_stub_clis + setup_stub_mise + touch "${DEPS_TMP}/package-lock.json" + + run install_deps "$DEPS_TMP" + assert_success + refute_output --partial "Using mise" + + grep -q '^npm install' "${DEPS_TMP}/calls.log" + ! grep -q '^mise' "${DEPS_TMP}/calls.log" +} + +@test "install_deps: WORKTREE_NO_MISE=1 bypasses mise entirely" { + setup_stub_clis + setup_stub_mise + touch "${DEPS_TMP}/mise.toml" + touch "${DEPS_TMP}/package-lock.json" + + export WORKTREE_NO_MISE=1 + run install_deps "$DEPS_TMP" + assert_success + refute_output --partial "Using mise" + + grep -q '^npm install' "${DEPS_TMP}/calls.log" + ! grep -q '^mise' "${DEPS_TMP}/calls.log" +} From a4a3e34efa70581ae53763c12afc83433ab3a7b3 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Sun, 26 Jul 2026 09:25:39 +0900 Subject: [PATCH 2/2] chore: address review nitpicks on CI hardening and SKILL.md - set persist-credentials: false on every actions/checkout step. No job pushes or authenticates over git: the test suites clone public repos themselves and `gh` reads GH_TOKEN from the environment, so the checkout credentials are never needed after checkout - keep the two adjacent notes in worktree-checkout/SKILL.md as separate blockquotes (markdownlint MD028) Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 ++++ skills/worktree-checkout/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccb1b6c..ec80c16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true + persist-credentials: false - name: Install modern Bash (macOS) if: runner.os == 'macOS' run: | @@ -47,6 +48,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true + persist-credentials: false - name: Install modern Bash (macOS) if: runner.os == 'macOS' run: | @@ -65,6 +67,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true + persist-credentials: false - name: Install modern Bash (macOS) if: runner.os == 'macOS' run: | @@ -83,5 +86,6 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: submodules: true + persist-credentials: false - name: Run self-repo smoke tests run: ./test/bats/bin/bats test/self_repo_smoke.bats diff --git a/skills/worktree-checkout/SKILL.md b/skills/worktree-checkout/SKILL.md index 7cf9d25..66d9e32 100644 --- a/skills/worktree-checkout/SKILL.md +++ b/skills/worktree-checkout/SKILL.md @@ -56,7 +56,7 @@ worktree checkout https://github.com/owner/repo/pull/123 --no-cd > install runs through `mise exec --` (after `mise trust`) so mise-managed > toolchains are on `PATH`. This trusts the config as checked out on the PR > branch; use `WORKTREE_NO_MISE=1` or `--no-install` for untrusted PRs. - +> > **Note (subagents):** In URL mode, `checkout` auto-cds into the new worktree > only in an interactive shell that has `eval "$(worktree shell-init)"` installed. > From a subagent's `Bash` call there is no directory change, so `--no-cd` has no