diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d7a228..fb0ff26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,14 @@
**反映には `devbase build base --no-cache` と、使っている派生イメージの建て直し、
コンテナの作り直し(`devbase down` → `devbase up`)が要ります。`devbase up` だけでは
反映されません。**
+- **コンテナを作り直しても残るシェルの設定の置き場所 `~/.shellrc.d/` を用意しました(PLAN70 / #253)。**
+ 置き場所へ `*.sh` を置くと、対話シェル(`devbase login` / tmux の窓 / VS Code の端末)の起動時に
+ ファイル名の順で読まれます。実体はアカウントグループのボリューム(`/persistent/group/.shellrc.d/`)に
+ あり、同じグループのコンテナすべてで効きます。AI CLI の起動定義の後に読むので、同じ名前の alias は
+ 置き場所の定義が勝ちます。置き場所のパスはコンテナの環境変数 `DEVBASE_SHELLRC_DIR` で、非対話の
+ 処理からも見えます。設定を足すツールはこの変数の指す先へ 1 ファイル置けば、`~/.bashrc` を
+ 書き換えずに済みます。zsh と `lfm` は対象外です。
+ **反映には `devbase build base --no-cache` と、使っている派生イメージの建て直しが要ります。**
### Changed
- **スナップショットの世代を、アカウントグループ(対象ボリュームの組)ごとの系列で持つように
diff --git a/containers/base/Dockerfile b/containers/base/Dockerfile
index 57e0237..5386a61 100644
--- a/containers/base/Dockerfile
+++ b/containers/base/Dockerfile
@@ -235,9 +235,18 @@ RUN set -eux; \
RUN sudo install -d -m 0755 /etc/devbase
COPY --chmod=0644 ai-cli-aliases.sh /etc/devbase/ai-cli-aliases.sh
+# 作り直しても残るシェルの設定の読み込み器 (PLAN70)。置き場所の実体はアカウント
+# グループのボリュームにあり、entrypoint.sh が ~/.shellrc.d を張る。パスを ENV で
+# 示すのは、docker exec の非対話の処理 (Claude Code の Bash など) からも置き場所を
+# 見つけられるようにするため。~/.bashrc の export では非対話の処理に届かない。
+ENV DEVBASE_SHELLRC_DIR=/home/${USERNAME}/.shellrc.d
+COPY --chmod=0644 shellrc-dir.sh /etc/devbase/shellrc-dir.sh
+
RUN set -eux; \
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc; \
echo '. /etc/devbase/ai-cli-aliases.sh' >> ~/.bashrc; \
+ # 起動定義の後に読み、置き場所の定義が devbase の定義より勝つようにする
+ echo '. /etc/devbase/shellrc-dir.sh' >> ~/.bashrc; \
git config --global credential.helper store; \
# Configure Claude Code hooks
mkdir -p ~/.claude; \
diff --git a/containers/base/entrypoint.sh b/containers/base/entrypoint.sh
index aca5829..064462e 100644
--- a/containers/base/entrypoint.sh
+++ b/containers/base/entrypoint.sh
@@ -206,6 +206,10 @@ DEVBASE_GROUP_SETTINGS=(
".claude"
".gemini"
".local/share/kiro-cli"
+ # 作り直しても残るシェルの設定の置き場所 (PLAN70)。~/.bashrc が
+ # /etc/devbase/shellrc-dir.sh 経由で中の *.sh を読む。ai-plugins の中継の本体が
+ # ~/.claude にあるので、同じグループの単位にそろえる
+ ".shellrc.d"
)
# 分類 B のうち、既存コンテナのホームから初回だけ取り込むもの。
diff --git a/containers/base/shellrc-dir.sh b/containers/base/shellrc-dir.sh
new file mode 100644
index 0000000..5dd4116
--- /dev/null
+++ b/containers/base/shellrc-dir.sh
@@ -0,0 +1,33 @@
+# 作り直しても残るシェルの設定を読む (PLAN70)。
+#
+# 対話シェルの ~/.bashrc から、/etc/devbase/ai-cli-aliases.sh の**後**に読まれる。
+# 置き場所 (DEVBASE_SHELLRC_DIR、空なら ~/.shellrc.d) の直下の *.sh を名前の順に読む。
+# 置き場所の実体はアカウントグループのボリュームにあり、entrypoint.sh が symlink を張る。
+#
+# - 外部コマンドもサブシェルも起動しない (対話シェルの起動を遅くしないため)
+# - グロブの展開の間だけ failglob と dotglob を切り、読む前に利用者の状態へ戻す。
+# failglob が有効なまま一致が無いと no match を出し、dotglob が有効だと *.sh が
+# . で始まる名前にも一致する
+# - 変数名を __devbase_ で始め、最後に消す。利用者の変数 (f など) を上書きしない
+__devbase_shellrc_dir="${DEVBASE_SHELLRC_DIR:-$HOME/.shellrc.d}"
+if [ -d "$__devbase_shellrc_dir" ]; then
+ # 1 つずつ別の変数に控える。1 本の文字列にまとめて分割で戻すと、利用者の IFS に
+ # 空白が無いとき (IFS=$'\n\t' など) に分割されず戻せない
+ __devbase_shellrc_failglob=
+ __devbase_shellrc_dotglob=
+ shopt -q failglob && __devbase_shellrc_failglob=1
+ shopt -q dotglob && __devbase_shellrc_dotglob=1
+ shopt -u failglob dotglob
+ __devbase_shellrc_files=("$__devbase_shellrc_dir"/*.sh)
+ [ -n "$__devbase_shellrc_failglob" ] && shopt -s failglob
+ [ -n "$__devbase_shellrc_dotglob" ] && shopt -s dotglob
+ # 一致が無いときはグロブが文字列のまま残る。-f の判定で落ちる
+ for __devbase_shellrc_file in "${__devbase_shellrc_files[@]}"; do
+ if [ -f "$__devbase_shellrc_file" ] && [ -r "$__devbase_shellrc_file" ]; then
+ . "$__devbase_shellrc_file"
+ fi
+ done
+fi
+# 終了状態を 0 にする役も兼ねる (最後のファイルが読めなくても非 0 を残さない)
+unset __devbase_shellrc_dir __devbase_shellrc_file __devbase_shellrc_files \
+ __devbase_shellrc_failglob __devbase_shellrc_dotglob
diff --git a/docs/specifications/shellrc-dir.md b/docs/specifications/shellrc-dir.md
new file mode 100644
index 0000000..6975fbe
--- /dev/null
+++ b/docs/specifications/shellrc-dir.md
@@ -0,0 +1,245 @@
+# 作り直しても残るシェルの設定(`~/.shellrc.d`)
+
+## 概要
+
+base イメージのコンテナは、シェルの設定の置き場所 `~/.shellrc.d/` を持つ。実体はアカウント
+グループのボリューム(`/persistent/group/.shellrc.d/`)にあり、コンテナを作り直しても残る。
+対話の bash は起動時に、置き場所の直下の `*.sh` を名前の順に読む。
+
+置き場所のパスはコンテナの環境変数 `DEVBASE_SHELLRC_DIR` が示す。対話シェルでない処理
+(`docker exec` の中や Claude Code の Bash)からも見えるので、設定を足すツールは
+`~/.bashrc` を書き換えずに、この変数の指す先へ 1 ファイルを置けばよい。最初の使い手は
+ai-plugins の中継(devbasex/ai-plugins#928)である。
+
+利用者向けの使い方(置き方・置くファイルの作法)は
+[コンテナ操作ガイド: 作り直しても残るシェルの設定](../user/container-operations.md#作り直しても残るシェルの設定)
+にある。
+
+## 対象範囲
+
+- 置き場所の永続化(`entrypoint.sh` の分類 B のエントリ)と、置き場所を読む読み込み器
+- 環境変数 `DEVBASE_SHELLRC_DIR`
+- base から派生するイメージ(`general` / `go` / `php` / `php85` / `bi-tools` / `latex` /
+ `trygroup`)への伝播の規則
+- 対象に含まないもの:
+ - zsh。base に zsh は入っておらず、`~/.zshrc` を読むシェルがいない。zsh を入れる変更の
+ ときに起動定義と一緒に読み込みを足す(置き場所の名前はシェルに依らないので変えずに済む)
+ - `containers/lfm` と `containers/snapshot`。base を継がない(「伝播の規則」)
+ - 全コンテナ共通(分類 A)の置き場所。グループをまたいで効かせたい設定の置き場所は作らない
+ - 置き場所へ最初から入れておくファイル。devbase は置き場所へ何も書かない
+
+## 構成要素
+
+| 要素 | 置き場所 | 責務 |
+| --- | --- | --- |
+| 永続化のエントリ | `containers/base/entrypoint.sh` の `DEVBASE_GROUP_SETTINGS` の末尾の `".shellrc.d"` | 既存の `devbase_link_setting` が、グループ側に空のディレクトリを作り、`~/.shellrc.d` をそこへの symlink にする |
+| 読み込み器 | `containers/base/shellrc-dir.sh` → イメージの `/etc/devbase/shellrc-dir.sh`(`0644`) | 置き場所の `*.sh` を名前の順に読み、使った変数を消す |
+| 環境変数 | `containers/base/Dockerfile` の `ENV DEVBASE_SHELLRC_DIR=/home/${USERNAME}/.shellrc.d` | 置き場所のパスを、非対話の処理と派生イメージへ示す |
+| `~/.bashrc` の 1 行 | 同じ Dockerfile の `RUN` | `. /etc/devbase/ai-cli-aliases.sh` の行の**次**に `. /etc/devbase/shellrc-dir.sh` を書く |
+
+```mermaid
+graph TD
+ subgraph イメージ
+ E[ENV DEVBASE_SHELLRC_DIR]
+ L[読み込み器
/etc/devbase/shellrc-dir.sh]
+ A[AI CLI の起動定義
/etc/devbase/ai-cli-aliases.sh]
+ R[~/.bashrc の末尾]
+ EP[entrypoint の
分類 B の一覧]
+ end
+ subgraph コンテナの起動時
+ S["~/.shellrc.d → /persistent/group/.shellrc.d"]
+ end
+ subgraph アカウントグループのボリューム
+ D["/persistent/group/.shellrc.d/*.sh"]
+ end
+ EP -->|symlink を張る| S
+ S --> D
+ R -->|1. 読む| A
+ R -->|2. 読む| L
+ L -->|パスを読む| E
+ L -->|名前の順に読む| D
+```
+
+読み込みを `~/.bashrc` へ直接書かずファイルにしているのは、Docker を起動しないテストで
+振る舞いを固定するためである(`ai-cli-aliases.sh` と同じ形)。読み込みの中身を直しても
+`~/.bashrc` の行は変わらない。
+
+## 仕様
+
+### 外への約束
+
+devbase が外へ約束するのは、環境変数 `DEVBASE_SHELLRC_DIR` と、その既定値の置き場所
+`~/.shellrc.d/` の 2 つである。
+
+| 項目 | 約束 |
+| --- | --- |
+| 値 | `/home/ubuntu/.shellrc.d`(絶対パス。イメージの `ENV` が決める) |
+| 存在 | entrypoint が終わった後(`/tmp/entrypoint-ready` がある時点)、値のパスは開発ユーザーが所有し書けるディレクトリで、実体はアカウントグループのボリュームにある |
+| 共有の単位 | アカウントグループ。同じグループのコンテナすべてで同じ設定が効き、別のグループには効かない |
+| 互換性 | 変数が無い(古いイメージ)ときは置き場所も無い。変数の有無で判定し、無ければ建て直しを案内するのは使う側(ai-plugins など)の扱いで、devbase は約束しない |
+
+名前の採否: `~/.shellrc.d` はシェルの名前を含まず、後で zsh を足しても同じ置き場所を読ませ
+られる。変数は、イメージの開発ユーザーのホームで値が決まるのでイメージの `ENV` で定める。
+ホストが生成する compose で渡すと、古いイメージのコンテナへも存在しない置き場所を指す変数が
+渡る。entrypoint の `export` は `docker exec` のシェルに届かない。
+
+分類 B(グループ単位)に置くのは、最初の使い手である中継の本体が `~/.claude/ndf/`(分類 B)に
+あるためである。分類 A にすると、中継の本体が無い別のグループでも中継を読む行が効いてしまう。
+
+### 読む先
+
+- 読む先は `DEVBASE_SHELLRC_DIR` の値で、変数が空か未設定なら `$HOME/.shellrc.d` である。
+ 置く側と読む側が同じ 1 つの値から決まる
+- 変数が**空でない**のにその先がディレクトリでないときは、何も読まない。`$HOME/.shellrc.d` へは
+ 戻らない
+- 置き場所そのものが symlink でも辿って読む(実配置は `~/.shellrc.d` がグループのボリュームへの
+ symlink である)
+- 変数を既定から別の場所へ向けると、読み込みもそちらへ移る。向けた先は永続化の対象ではない
+
+### 読むもの・読む順
+
+- 置き場所の**直下**の、名前が `.sh` で終わるもので、`[ -f ]` と `[ -r ]` を満たすもの(通常
+ ファイル、または通常ファイルを指す symlink で、読めるもの)を読む
+- 読まないもの: サブディレクトリの中、`.` で始まる名前(`dotglob` が有効でも)、他の拡張子や
+ 拡張子の無い名前、`*.sh` という名前のディレクトリ
+- 順序はグロブの展開順(ファイル名の昇順)。同じ alias を複数のファイルが定義すると、後ろの
+ ファイルの定義が残る
+- `ai-cli-aliases.sh` の**後**に読むので、同じ名前の alias(`claude` など)は置き場所の定義が
+ devbase の定義より勝つ
+- ディレクトリ名・ファイル名に空白やグロブ文字(`[x]` / `*`)が含まれても、単語分割も再展開も
+ されずに読む
+
+### 読まれる時点
+
+対話の bash の起動時だけである。`devbase login`(`docker compose exec ... bash`、対話の非ログイン
+シェル)は `~/.bashrc` を読み、tmux の窓(ログインシェル)は `~/.profile` 経由で `~/.bashrc` を
+読む。`~/.bashrc` は先頭で非対話シェルを帰す(Ubuntu の既定)ため、読み込みの 1 行は非対話の
+処理では実行されない。非対話の処理へ届くのは環境変数だけである。置いたファイルは次に開く
+シェルから効く。
+
+### 常に成り立つ条件
+
+- **読み込みの前後で `failglob` / `dotglob` の状態は変わらない。** グロブを展開する間だけ両方を
+ 切り、展開の結果を配列へ移し、**読む前に**有効だったものを戻す。置き場所のファイルは利用者の
+ 設定のまま読まれ、ファイルの中で変えた設定(`shopt -s failglob` など)は後ろのファイルと
+ 読み込みの後にも残る
+- **控えと戻しは `IFS` に依らない。** 有効だった設定を `shopt -q` で 1 つずつ別の変数に控え、
+ `shopt -s` で 1 つずつ戻す。名前を 1 本の文字列にまとめて単語分割で戻す形は、利用者の `IFS` に
+ 空白が無いとき(`IFS=$'\n\t'` など)に戻せないため採らない
+- **`nullglob` の状態に依らない。** 一致が無いとグロブは文字列のまま残り、`[ -f ]` で落ちる。
+ `nullglob` が有効なら繰り返しが 0 回になるだけで、結果は同じである
+- **変数を残さない。** 読み込み器が使う変数は `__devbase_shellrc_` で始め、最後に `unset` する。
+ 利用者が先に置いた変数(`f` など)は変わらない。置き場所のファイルが同じ名前の変数を使う
+ 場合までは守らない
+- **外部コマンドもサブシェルも起動しない。** 組み込み(`[`・`if`・`for`・`.`・`shopt`・`unset`)と
+ 代入だけで書き、コマンド置換(`$(...)` / `` ` ``)とパイプを使わない。置き場所が空のときの
+ 追加の処理は、ディレクトリの判定、設定 2 つの控えと戻し、1 回のグロブで終わる。`PATH` が空でも
+ 動く
+- **読み込み器の終了状態は 0 である。** 各ファイルを `if` で包み、最後の `unset` で終える。
+ 最後のファイルが読めなくても、`~/.bashrc` の直後の `$?` に非 0 を残さない
+- bash 3.2(macOS の `/bin/bash`)でも同じ結果になる。テストはホストの bash でも走る
+
+### エラー処理
+
+| 場合 | 扱い |
+| --- | --- |
+| 置き場所が無い・ディレクトリでない・空・`*.sh` が 1 つも無い | **黙って飛ばす。** 何も出力せず、終了状態 0。`failglob` が有効でも `no match` を出さない |
+| 読み取り権限の無いファイル、リンク切れの symlink | **黙って飛ばし**、次のファイルへ進む |
+| ファイルの中の構文・実行のエラー | **知らせる。** bash がそのファイルの誤りとして標準エラーに出し、次のファイルへ進む。devbase は誤りを握りつぶさず、シェルの起動も止めない |
+
+置き場所のファイル自身が `exit` を実行すると対話シェルが終わる。これは防がない(利用者向け
+文書の作法で `exit` を書かないよう求める)。
+
+### 権限
+
+置き場所に書けるのは、同じアカウントグループのボリュームに書ける者だけである。既に
+`~/.claude`(hooks を含む)へ書ける者と同じ範囲で、新しい書き手を増やさない。実体は
+entrypoint(開発ユーザーで走る)が既存の `devbase_ensure_entry` で作るので、開発ユーザーの
+所有になる。イメージと entrypoint は置き場所へファイルを書かない。
+
+## データ・設定
+
+| 項目 | 値 |
+| --- | --- |
+| 環境変数 | `DEVBASE_SHELLRC_DIR=/home/ubuntu/.shellrc.d`(イメージの `ENV`) |
+| 置き場所 | `~/.shellrc.d` → `/persistent/group/.shellrc.d`(ボリューム `devbase_home_{group}`) |
+| 読み込み器 | `/etc/devbase/shellrc-dir.sh`(root 所有、`0644`。`/etc/devbase` は既存の `install -d -m 0755` が先に作る) |
+| `~/.bashrc` | 末尾に `. /etc/devbase/ai-cli-aliases.sh` → `. /etc/devbase/shellrc-dir.sh` の順 |
+
+`default` グループでは、初回シード(`devbase_seed_group_settings`)が `/persistent/ai/.shellrc.d`
+からのコピーを試み、シード元が無いので `skip (シード元なし)` の 1 行を出す。グループ側に
+`.shellrc.d` がまだ無い最初の起動の 1 回だけで、除外の一覧は持たない。
+
+## 運用
+
+- 変更は**イメージを建て直すまで反映されない**。読み込みの 1 行・読み込み器・`ENV`・
+ `entrypoint.sh` はどれもイメージの中にあり、`devbase up` だけでは反映されない。
+ `devbase build base --no-cache` で base を建て直し、使っている派生イメージ(いずれも
+ `FROM devbase-base:latest`)も建て直し、稼働中のコンテナは `devbase down` → `devbase up` で
+ 作り直す
+- `ENV` は派生イメージへ継がれ、`~/.bashrc` も base の層を継ぐので、派生イメージ側の変更は
+ 要らない
+- `containers/lfm` は base を継がず、base から `/entrypoint.sh` をコピーするだけである。lfm の
+ コンテナでも `~/.shellrc.d` の symlink は張られるが、`~/.bashrc` と `ENV` は lfm 自身の
+ Dockerfile が持つので読まれない。lfm は起動定義も `~/.bashrc` へ直書きしており、base に
+ そろえるのは別の課題である。`containers/snapshot` は base を継がない
+- 切り戻しはコミットの revert と base の建て直しで足りる。グループのボリュームに残る
+ `.shellrc.d/` は読まれなくなるだけで、消さなくても害はない
+- 建てて確かめてあるのは arm64 である。変更はシェルの断片・symlink の一覧・`ENV` で、
+ アーキテクチャに依存しない
+
+## テスト観点
+
+`tests/containers/test_shellrc_dir.py`(Docker を要さない。読み込み器を一時ディレクトリの
+`HOME` で `bash -c` から `shopt -s expand_aliases` を付けて source する):
+
+- 置き場所の `*.sh` がファイル名の昇順で全部読まれ、同じ alias は後ろの定義が残ること。
+ 通常ファイルを指す symlink も名前の順に読まれること
+- `x.txt` / `README` / `*.sh` という名前のディレクトリ / `.` で始まる名前が読まれないこと。
+ `dotglob` が有効でも `.` で始まる名前が読まれないこと
+- 置き場所が無い・空・`*.sh` が無いとき、標準出力と標準エラーが空で直後の `$?` が 0 であること。
+ `failglob` が有効でも同じであること
+- 有効だった `failglob` / `dotglob` が読む前に戻り、読んだ後も有効であること。片方だけ有効な
+ 場合と、`IFS` に空白が無い場合も同じであること。ファイルの中で有効にした設定が後ろのファイルと
+ 読み込みの後に残ること。無効だった設定は無効のままであること
+- 構文の誤りを持つファイルが標準エラーに出て、後ろのファイルが読まれること
+- 読めないファイルとリンク切れの symlink が、何も出さずに飛ばされ、後ろのファイルが読まれ、
+ 終了状態 0 であること
+- `ai-cli-aliases.sh` の後に読み込み器を source すると、置き場所の `alias claude` が残ること
+- 読み込みの後に `__devbase_` で始まる変数が残らず、先に置いた `f` の値が変わらないこと
+- 変数が指す場所を読むこと。変数が空・未設定なら `$HOME/.shellrc.d` を読むこと。変数が指す先が
+ 無いときは `$HOME/.shellrc.d` へ戻らず、何も出さないこと
+- 置き場所とファイルの名前に空白やグロブ文字があっても名前の順に読むこと。置き場所が symlink
+ でも辿ること
+- `PATH` を空にして source しても誤りが出ないこと。読み込み器の本文に `$(`・`` ` ``・`|` が
+ 無いこと
+- Dockerfile で、`/etc/devbase` を作った後に読み込み器を `COPY` すること、
+ `ENV DEVBASE_SHELLRC_DIR=/home/${USERNAME}/.shellrc.d` があること、`~/.bashrc` の読み込み器の
+ 行が `ai-cli-aliases.sh` の行より後であること、読み込み器を `.zshrc` へ書く行が無いこと
+
+`tests/containers/test_entrypoint_ai_settings.py`(`DEVBASE_ENTRYPOINT_LIB_ONLY=1` で関数を呼ぶ):
+
+- `~/.shellrc.d` がグループの根の下の `.shellrc.d` への symlink であること
+- entrypoint の後、グループ側の `.shellrc.d` が空のディレクトリであること(devbase は何も書かない)
+- 片方のグループの置き場所に置いたファイルが、もう片方のグループの置き場所から見えないこと
+- 既存の分類 A・B のエントリの張り先と、`default` グループの初回シードの結果が変わらないこと
+
+既存の `tests/containers/test_ai_cli_aliases.py` が通り、起動定義と起動オプションが変わらない
+ことも確かめる。
+
+建てたイメージで手で確かめる観点(CI はイメージを建てないため、CI では確かめない):
+
+- `devbase build base --no-cache` が成功すること
+- 作り直したコンテナで `~/.shellrc.d` が `/persistent/group/.shellrc.d` を指す symlink で、
+ その先が `ubuntu` の所有であること
+- `docker exec printenv DEVBASE_SHELLRC_DIR` が `/home/ubuntu/.shellrc.d` を出すこと
+- 置き場所に alias を定義したファイルを置き、`devbase down` → `devbase up` の後の
+ `devbase login` で alias が効くこと。同じグループの別のコンテナ(`--index=2`)でも効くこと
+- 建て直しの前後で `~/.zshrc` が変わらないこと
+
+## 関連リンク
+
+- [コンテナ操作ガイド: 作り直しても残るシェルの設定](../user/container-operations.md#作り直しても残るシェルの設定)
+- [AI CLI alias の読み込み](ai-cli-alias-loading.md)
+- [Issue #253](https://github.com/devbasex/devbase/issues/253)
+- [devbasex/ai-plugins#928](https://github.com/devbasex/ai-plugins/issues/928)
diff --git a/docs/user/container-operations.md b/docs/user/container-operations.md
index 6ec3d31..05c4408 100644
--- a/docs/user/container-operations.md
+++ b/docs/user/container-operations.md
@@ -146,7 +146,7 @@ devbase のコンテナは 4 種類のボリュームを使用します。
| ボリューム名 | マウント先 | 共有範囲 | 用途 |
|-------------|-----------|---------|------|
| `devbase_home_ubuntu` | `/persistent/ai` | 全コンテナで共有 | 契約やテナントに紐づかない共通資産(`~/.claude/plugins` / `skills` / `commands` / `CLAUDE.md` / `settings.json`、`.codex` / `.serena` / `.kiro`、SSH 鍵、共有ファイル置き場 `share`)|
-| `devbase_home_{group}` | `/persistent/group` | 同じアカウントグループのコンテナで共有 | 企業テナントに紐づくもの(Claude Code の認証と会話ログ、`.gemini`、Kiro CLI の `.local/share/kiro-cli`、gcloud / gws の設定ディレクトリ)|
+| `devbase_home_{group}` | `/persistent/group` | 同じアカウントグループのコンテナで共有 | 企業テナントに紐づくもの(Claude Code の認証と会話ログ、`.gemini`、Kiro CLI の `.local/share/kiro-cli`、gcloud / gws の設定ディレクトリ、[作り直しても残るシェルの設定](#作り直しても残るシェルの設定)の置き場所 `.shellrc.d`)|
| `devbase_work_{index}` | `/work` | 同じ index のコンテナで共有(プロジェクト間も共有) | プロジェクトのソースコード、作業ファイル |
| `devbase_vscode_{project}_{index}` | `/home/ubuntu/.vscode-server` | 共有しない(コンテナ 1 つに 1 本)| VS Code Server 本体・拡張機能・接続トークン |
@@ -273,6 +273,7 @@ AI CLI ツールの設定や認証情報は、コンテナを再生成しても
| `.claude` | Claude Code の認証・会話ログ・セッション状態(上表の共通資産を除く**すべて**)|
| `.gemini` | Gemini CLI と Antigravity CLI の設定(`vertex-ai` は GCP プロジェクトに紐づく。Antigravity CLI は `.gemini/antigravity-cli/` 配下を使う)|
| `.local/share/kiro-cli` | Kiro CLI 2.x の認証状態・実行データ |
+| `.shellrc.d` | 対話シェルが起動時に読む `*.sh` の置き場所。パスは `DEVBASE_SHELLRC_DIR` が示す。**反映には `devbase build base --no-cache` が要る**([作り直しても残るシェルの設定](#作り直しても残るシェルの設定))|
`~/.claude` は `/persistent/group/.claude` への symlink で、**その配下の既定はグループ側**です。
共通資産だけがその中から `/persistent/ai/.claude/` へ張り直されます。既定をグループ側に
@@ -508,6 +509,43 @@ GOOGLE_CLOUD_PROJECT=
`~/.gemini` はアカウントグループのボリューム(`/persistent/group/.gemini`)にあるため、
OAuth のログインはコンテナを作り直しても残ります。
+## 作り直しても残るシェルの設定
+
+`~/.bashrc` はイメージの中にあるため、書き足した alias や関数はコンテナを作り直すと消えます。
+作り直しても残したい設定は、置き場所 `~/.shellrc.d/` へ `*.sh` のファイルとして置きます。
+
+```bash
+cat > ~/.shellrc.d/my-aliases.sh <<'SH'
+alias ll='ls -alF'
+SH
+```
+
+置いたファイルは、次に開く対話シェル(`devbase login` / tmux の窓 / VS Code の端末)から効きます。
+
+| 項目 | 振る舞い |
+|------|---------|
+| 実体 | `/persistent/group/.shellrc.d/`。**同じアカウントグループのコンテナすべて**で同じ設定が効き、別のグループには効かない |
+| パス | 環境変数 `DEVBASE_SHELLRC_DIR`(`/home/ubuntu/.shellrc.d`)。`docker exec` の非対話の処理からも見える。設定を足すツールはこの変数の指す先へ 1 ファイル置き、`~/.bashrc` を書き換えない |
+| 読まれるもの | 置き場所の直下の、名前が `.sh` で終わるファイル。サブディレクトリの中・`.` で始まる名前・他の拡張子は読まない |
+| 読む順 | ファイル名の昇順。[AI CLI の起動定義](#ai-cli-の起動定義)の**後**に読むので、同じ名前の alias(`claude` など)は置き場所の定義が勝つ |
+| 読まれる時点 | 対話の bash の起動時だけ。スクリプトや `docker exec ... bash -c` などの非対話の処理では読まれない |
+| 誤り | ファイルの中の構文・実行のエラーは標準エラーに出て、次のファイルへ進む。読み取り権限の無いファイル・リンク切れの symlink は、何も出さずに飛ばす |
+
+置くファイルの作法:
+
+- 1 つの用途につき 1 ファイルにし、`<名前>.sh` とします。順序を決めたいときは `10-` のような数字を前に付けます
+- 何度読まれても同じ結果になるように書きます。`exit` は書きません(対話シェルが終わります)。標準出力へは何も出しません
+- devbase は置き場所へ何も書きません。置いたものを消すのは置いた側です
+- `DEVBASE_SHELLRC_DIR` を別の場所へ向けると、読み込みもそちらへ移ります。ただし向けた先は永続化されません
+
+**反映には `devbase build base --no-cache` が要ります。** `devbase up` だけでは反映されません
+(読み込みの 1 行と `DEVBASE_SHELLRC_DIR` はイメージの中にあります)。派生イメージを使う
+プロジェクトはその派生イメージも建て直し、稼働中のコンテナは `devbase down` → `devbase up` で
+作り直してください。zsh(base には入っていません)と `lfm` イメージは対象外です。
+
+読む先の決め方・読み込みの前後で保つ条件・エラーの扱いの仕様は
+[作り直しても残るシェルの設定(`~/.shellrc.d`)](../specifications/shellrc-dir.md)にあります。
+
## tmux(ターミナル)の既定設定
コンテナ内の tmux には、devbase 共通の既定設定 `/etc/tmux.conf` が入っています
diff --git a/issues/PLAN70_shellrc-dir-design.md b/issues/old/PLAN70_shellrc-dir-design.md
similarity index 96%
rename from issues/PLAN70_shellrc-dir-design.md
rename to issues/old/PLAN70_shellrc-dir-design.md
index b168804..377ab77 100644
--- a/issues/PLAN70_shellrc-dir-design.md
+++ b/issues/old/PLAN70_shellrc-dir-design.md
@@ -132,22 +132,22 @@ sequenceDiagram
# 置き場所の *.sh を名前の順に読む。対話シェルの ~/.bashrc から読まれる。
__devbase_shellrc_dir="${DEVBASE_SHELLRC_DIR:-$HOME/.shellrc.d}"
if [ -d "$__devbase_shellrc_dir" ]; then
- __devbase_shellrc_opts=
- shopt -q failglob && __devbase_shellrc_opts="$__devbase_shellrc_opts failglob"
- shopt -q dotglob && __devbase_shellrc_opts="$__devbase_shellrc_opts dotglob"
+ __devbase_shellrc_failglob=
+ __devbase_shellrc_dotglob=
+ shopt -q failglob && __devbase_shellrc_failglob=1
+ shopt -q dotglob && __devbase_shellrc_dotglob=1
shopt -u failglob dotglob
__devbase_shellrc_files=("$__devbase_shellrc_dir"/*.sh)
- if [ -n "$__devbase_shellrc_opts" ]; then
- # 名前ごとに分けて渡すため、引用符で囲まない
- shopt -s $__devbase_shellrc_opts
- fi
+ [ -n "$__devbase_shellrc_failglob" ] && shopt -s failglob
+ [ -n "$__devbase_shellrc_dotglob" ] && shopt -s dotglob
for __devbase_shellrc_file in "${__devbase_shellrc_files[@]}"; do
if [ -f "$__devbase_shellrc_file" ] && [ -r "$__devbase_shellrc_file" ]; then
. "$__devbase_shellrc_file"
fi
done
fi
-unset __devbase_shellrc_dir __devbase_shellrc_file __devbase_shellrc_files __devbase_shellrc_opts
+unset __devbase_shellrc_dir __devbase_shellrc_file __devbase_shellrc_files \
+ __devbase_shellrc_failglob __devbase_shellrc_dotglob
```
- 一致が無いとき bash のグロブは文字列のまま残る。`-f` の判定で落ちるので、`nullglob` を
@@ -155,8 +155,9 @@ unset __devbase_shellrc_dir __devbase_shellrc_file __devbase_shellrc_files __dev
- グロブの展開の間だけ `failglob` と `dotglob` を切る。`failglob` が有効なまま一致が無いと、
bash は `no match` を標準エラーへ出して展開した文を実行しない(受け入れ条件 6)。`dotglob` が
有効だと `*.sh` が `.` で始まる名前にも一致する(受け入れ条件 5)
-- 有効だった設定の名前を `shopt -q` で控え、展開の結果を配列へ移し、**読む前に** `shopt -s` で
- 戻す。置き場所のファイルは利用者の設定のまま読まれ、ファイルの中で変えた設定は読み込みの
+- 有効だった設定を `shopt -q` で 1 つずつ別の変数に控え、展開の結果を配列へ移し、**読む前に**
+ `shopt -s` で 1 つずつ戻す。名前を 1 本の文字列にまとめて単語分割で戻すと、利用者の `IFS` に
+ 空白が無いとき(`IFS=$'\n\t'` など)に分割されず戻せない。置き場所のファイルは利用者の設定のまま読まれ、ファイルの中で変えた設定は読み込みの
後も残る(受け入れ条件 6a)。控えに `$(shopt -p ...)` と `eval` を使わないのは、サブシェルを
作らないためである(非機能の条件)
- `if` で包むのは、最後のファイルが読めないときに `&&` の連なりが非 0 を残さないためである。
diff --git a/issues/PLAN70_shellrc-dir.md b/issues/old/PLAN70_shellrc-dir.md
similarity index 80%
rename from issues/PLAN70_shellrc-dir.md
rename to issues/old/PLAN70_shellrc-dir.md
index 18ed497..efa3693 100644
--- a/issues/PLAN70_shellrc-dir.md
+++ b/issues/old/PLAN70_shellrc-dir.md
@@ -167,8 +167,63 @@
## 実装計画
-設計は [PLAN70_shellrc-dir-design.md](PLAN70_shellrc-dir-design.md)。
-**タスクへの分解は実装の持ち場で `/ndf:implementation-plan` が行う。**
+設計は [PLAN70_shellrc-dir-design.md](PLAN70_shellrc-dir-design.md)。タスクは機能単位で分け、
+どれもテストを先に書いて失敗を見てから実装する(`/ndf:tdd-cycle`)。
+
+### Task 1: 読み込み器
+
+- **対象ファイル:** `containers/base/shellrc-dir.sh`(新設)、`tests/containers/test_shellrc_dir.py`(新設)
+- **変更内容:** 設計の「処理の流れ」の形で読み込み器を書く。テストは読み込み器を一時ディレクトリで
+ `bash -c` から source し(`shopt -s expand_aliases`)、読む順・対象・無い/空・グロブの設定の
+ 控えと戻し・誤りの後も続くこと・変数の後始末・変数と既定を固定する。`PATH=` を空にした source と、
+ 読み込み器の文字列に `$(`・`` ` ``・`|` が無いことで性能の条件を見る
+- **満たす受け入れ条件:** 4〜7・6a・9・10(と非機能の性能)
+
+### Task 2: イメージへの配置と環境変数
+
+- **対象ファイル:** `containers/base/Dockerfile`、`tests/containers/test_shellrc_dir.py`
+- **変更内容:** `ENV DEVBASE_SHELLRC_DIR=/home/${USERNAME}/.shellrc.d` と
+ `COPY --chmod=0644 shellrc-dir.sh /etc/devbase/shellrc-dir.sh` を `ai-cli-aliases.sh` の `COPY` の
+ 次へ置き、`~/.bashrc` へ `. /etc/devbase/shellrc-dir.sh` を `ai-cli-aliases.sh` の行の次に足す。
+ テストは Dockerfile の文字列で、配置・行の順序・`.zshrc` へ書き込む行が無いことを固定し、
+ `ai-cli-aliases.sh` の後に読み込み器を source して置き場所の `alias claude` が勝つことを見る
+- **満たす受け入れ条件:** 8・11(Dockerfile の部分)・12・14
+
+### Task 3: 置き場所の永続化
+
+- **対象ファイル:** `containers/base/entrypoint.sh`、`tests/containers/test_entrypoint_ai_settings.py`
+- **変更内容:** `DEVBASE_GROUP_SETTINGS` の末尾へ `".shellrc.d"` を足す。テストは分類 B の張り先の
+ 一覧へ `.shellrc.d` を足し、グループの分離の検査へ置き場所の分離を足す。entrypoint の後の置き場所が
+ 空であることも見る
+- **満たす受け入れ条件:** 1・3(関数の部分)・13
+
+### Task 4: 文書と CHANGELOG
+
+- **対象ファイル:** `docs/user/container-operations.md`、`CHANGELOG.md`
+- **変更内容:** 受け入れ条件 17 の 4 か所。置き場所へ置くファイルの作法(設計の「入出力の契約」)と、
+ 変数を既定から変えた先は永続化されないことを書く
+- **満たす受け入れ条件:** 17
+- **進め方:** 文書のみでテスト駆動を適用しない。目視で 4 か所を確かめる
+
+### Task 5: 建て直しと実機の確認
+
+- **変更内容:** `uv run --locked pytest tests/ -q`、`devbase build base --no-cache`(arm64)、
+ 建て直したイメージでの `readlink` / `stat` / `printenv` / `~/.zshrc` の比較、実プロジェクトでの
+ `devbase down` → `devbase up` を挟んだ `plan70probe`。出力を Pull Request 本文へ載せる
+- **満たす受け入れ条件:** 1・2・3(実機)・11・14・15・16
+
+### リスクと対処
+
+| リスク | 対処 |
+| --- | --- |
+| Dockerfile の同じ付近を #234(PLAN69)が後から触る | 触る行は `COPY` と `~/.bashrc` の `RUN` に限る。後からマージする側が載せ直す |
+| テストがホストの bash 3.2 で走る | 読み込み器は bash 3.2 でも同じ結果になる形(設計の実測)。配列の空展開は一致なしでも要素 1 つ(文字列が残る)なので起きない |
+| 触る対象の構造 | 一覧に 1 項目足すだけで関数は変えない。実装の後の構造改善で足りる |
+
+### 切り戻し
+
+コミットを revert し、`devbase build base --no-cache` で建て直す。グループのボリュームに残る
+`.shellrc.d/` は読まれなくなるだけで、消さなくても害はない。
## 未確認のまま残ること
diff --git a/tests/containers/test_entrypoint_ai_settings.py b/tests/containers/test_entrypoint_ai_settings.py
index 70bd8d6..99ef6b2 100644
--- a/tests/containers/test_entrypoint_ai_settings.py
+++ b/tests/containers/test_entrypoint_ai_settings.py
@@ -73,12 +73,23 @@ def test_group_entries_point_at_the_group_volume(roots):
home, _, grp = roots
setup(roots, "kkg")
- for entry in (".claude.json", ".claude", ".gemini", ".local/share/kiro-cli"):
+ for entry in (".claude.json", ".claude", ".gemini", ".local/share/kiro-cli",
+ ".shellrc.d"):
link = home / entry
assert link.is_symlink(), f"{entry} が symlink ではない"
assert link.resolve() == (grp / entry).resolve()
+def test_shellrc_dir_is_an_empty_group_directory(roots):
+ """PLAN70: 置き場所はグループ側のディレクトリで、devbase は中へ何も書かない。"""
+ home, _, grp = roots
+ setup(roots, "kkg")
+
+ target = grp / ".shellrc.d"
+ assert target.is_dir() and not target.is_symlink()
+ assert list(target.iterdir()) == []
+
+
def test_kiro_cli_data_points_at_the_group_volume(roots):
home, _, grp = roots
setup(roots, "kkg")
@@ -192,6 +203,11 @@ def test_two_groups_share_assets_but_not_credentials(roots, tmp_path):
(home_a / ".claude" / ".credentials.json").write_text("default-secret")
assert not (home_b / ".claude" / ".credentials.json").exists()
+ # PLAN70: シェルの設定の置き場所もグループごとに分かれる
+ (home_a / ".shellrc.d" / "plan70.sh").write_text("alias plan70probe='echo kept'\n")
+ assert (group_a / ".shellrc.d" / "plan70.sh").exists()
+ assert not (home_b / ".shellrc.d" / "plan70.sh").exists()
+
# ---------------------------------------------------------------------------
# 入れ子パス (AC6 / 前提 5)
diff --git a/tests/containers/test_shellrc_dir.py b/tests/containers/test_shellrc_dir.py
new file mode 100644
index 0000000..5981564
--- /dev/null
+++ b/tests/containers/test_shellrc_dir.py
@@ -0,0 +1,419 @@
+"""作り直しても残るシェルの設定の読み込み器 (PLAN70)
+
+``containers/base/shellrc-dir.sh`` を対話シェル相当 (``shopt -s expand_aliases``) で
+source し、置き場所に置いた ``*.sh`` の読まれ方を確かめる。Docker には依存しない
+(``tests/containers/test_ai_cli_aliases.py`` と同じ方式)。
+
+固定する契約:
+
+- 置き場所は ``DEVBASE_SHELLRC_DIR``、空か未設定なら ``$HOME/.shellrc.d``
+- 置き場所の直下の ``*.sh`` を名前の昇順で全部読む。``.`` で始まる名前・他の拡張子・
+ ディレクトリは読まない。1 つの誤りで後ろを止めない
+- 置き場所が無い・空でも何も出さず終了状態 0。``failglob`` / ``dotglob`` は利用者の
+ 状態のまま読み、読んだ後も残す
+- 読み込み器の変数をシェルに残さない。外部コマンドもサブシェルも起動しない
+- base イメージは ``~/.bashrc`` で ``ai-cli-aliases.sh`` の**後**に読み込み器を読み、
+ ``DEVBASE_SHELLRC_DIR`` をイメージの ``ENV`` で示す
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+BASE = Path(__file__).resolve().parents[2] / "containers" / "base"
+LOADER = BASE / "shellrc-dir.sh"
+ALIASES = BASE / "ai-cli-aliases.sh"
+DOCKERFILE = BASE / "Dockerfile"
+#: PATH を空にして走らせる検査でも起動できるよう、bash は絶対パスで呼ぶ
+BASH = shutil.which("bash") or "/bin/bash"
+
+
+def _run(script: str, home: Path, env: dict | None = None,
+ before: str = "", path: str | None = None) -> subprocess.CompletedProcess:
+ """対話シェル相当で ``before`` → 読み込み器 → ``script`` の順に実行する。"""
+ base = {k: v for k, v in os.environ.items() if not k.startswith("DEVBASE_")}
+ base["HOME"] = str(home)
+ if path is not None:
+ base["PATH"] = path
+ base.update(env or {})
+ return subprocess.run(
+ [BASH, "-c", f'shopt -s expand_aliases\n{before}\n. "{LOADER}"\n{script}'],
+ capture_output=True, text=True, env=base,
+ )
+
+
+@pytest.fixture
+def home(tmp_path: Path) -> Path:
+ h = tmp_path / "home"
+ h.mkdir()
+ return h
+
+
+@pytest.fixture
+def rcdir(home: Path) -> Path:
+ d = home / ".shellrc.d"
+ d.mkdir()
+ return d
+
+
+def _alias(result: subprocess.CompletedProcess, name: str) -> str:
+ for line in result.stdout.splitlines():
+ if line.startswith(f"alias {name}="):
+ return line
+ return ""
+
+
+# ===========================================================================
+# 受け入れ条件 4: 名前の昇順で全部読む
+# ===========================================================================
+
+def test_symlink_to_existing_file_is_read_in_name_order(home, rcdir, tmp_path):
+ """実在するファイルへの symlink はリンク名の順に読む(現状固定)。"""
+ target = tmp_path / "target.sh"
+ target.write_text("echo read-target\n")
+ (rcdir / "20-link.sh").symlink_to(target)
+ (rcdir / "10-a.sh").write_text("echo read-a\n")
+ (rcdir / "30-b.sh").write_text("echo read-b\n")
+
+ result = _run("", home)
+
+ assert result.stdout.splitlines() == ["read-a", "read-target", "read-b"]
+ assert result.stderr == ""
+
+
+def test_files_are_read_in_name_order(home, rcdir):
+ (rcdir / "20-b.sh").write_text("alias probe='echo b'\n")
+ (rcdir / "10-a.sh").write_text("alias probe='echo a'\nalias only_a='echo a'\n")
+
+ result = _run("probe; only_a", home)
+
+ assert result.returncode == 0, result.stderr
+ assert result.stdout.splitlines() == ["b", "a"]
+
+
+# ===========================================================================
+# 受け入れ条件 5: *.sh 以外・. で始まる名前・ディレクトリを読まない
+# ===========================================================================
+
+@pytest.mark.parametrize("before", ["", "shopt -s dotglob"])
+def test_only_plain_sh_files_are_read(home, rcdir, before):
+ (rcdir / "x.txt").write_text("echo read-txt\n")
+ (rcdir / "README").write_text("echo read-readme\n")
+ (rcdir / ".hidden.sh").write_text("echo read-hidden\n")
+ (rcdir / "sub.sh").mkdir()
+ (rcdir / "sub.sh" / "inner.sh").write_text("echo read-inner\n")
+ (rcdir / "ok.sh").write_text("echo read-ok\n")
+
+ result = _run("", home, before=before)
+
+ assert result.stdout.splitlines() == ["read-ok"]
+ assert result.stderr == ""
+
+
+# ===========================================================================
+# 受け入れ条件 6: 無い・空・一致なしで何も出さず 0
+# ===========================================================================
+
+@pytest.mark.parametrize("state", ["missing", "empty", "no-match"])
+@pytest.mark.parametrize("before", ["", "shopt -s failglob"])
+def test_missing_or_empty_directory_is_silent(home, state, before):
+ if state != "missing":
+ (home / ".shellrc.d").mkdir()
+ if state == "no-match":
+ (home / ".shellrc.d" / "x.txt").write_text("echo read-txt\n")
+
+ result = _run('echo "rc=$?"', home, before=before)
+
+ assert result.stdout == "rc=0\n"
+ assert result.stderr == ""
+
+
+# ===========================================================================
+# 受け入れ条件 6a: 利用者の failglob / dotglob のまま読み、後にも残す
+# ===========================================================================
+
+SHOW = 'shopt -q failglob && echo fg=on || echo fg=off; shopt -q dotglob && echo dg=on || echo dg=off'
+
+
+def test_glob_options_are_restored_before_reading(home, rcdir):
+ (rcdir / "10-show.sh").write_text(f"{SHOW}\n")
+
+ result = _run(SHOW, home, before="shopt -s failglob dotglob")
+
+ assert result.stdout.splitlines() == ["fg=on", "dg=on", "fg=on", "dg=on"]
+
+
+@pytest.mark.parametrize(
+ ("before", "expected"),
+ [
+ ("shopt -s failglob; shopt -u dotglob", ["fg=on", "dg=off", "fg=on", "dg=off"]),
+ ("shopt -u failglob; shopt -s dotglob", ["fg=off", "dg=on", "fg=off", "dg=on"]),
+ ],
+)
+def test_one_glob_option_on_is_restored_before_reading(home, rcdir, before, expected):
+ (rcdir / "10-show.sh").write_text(f"{SHOW}\n")
+
+ result = _run(SHOW, home, before=before)
+
+ assert result.stdout.splitlines() == expected
+ assert result.stderr == ""
+
+
+def test_glob_options_are_restored_when_ifs_has_no_space(home, rcdir):
+ """利用者の IFS に空白が無くても、控えた failglob / dotglob を戻す。"""
+ (rcdir / "10-show.sh").write_text(f"{SHOW}\n")
+
+ result = _run(SHOW, home, before="IFS=$'\\n\\t'; shopt -s failglob dotglob")
+
+ assert result.stdout.splitlines() == ["fg=on", "dg=on", "fg=on", "dg=on"]
+ assert result.stderr == ""
+
+
+def test_option_set_by_a_file_is_kept(home, rcdir):
+ (rcdir / "10-set.sh").write_text("shopt -s failglob\n")
+ (rcdir / "20-show.sh").write_text("shopt -q failglob && echo fg=on || echo fg=off\n")
+
+ result = _run("shopt -q failglob && echo fg=on || echo fg=off", home,
+ before="shopt -u failglob")
+
+ assert result.stdout.splitlines() == ["fg=on", "fg=on"]
+
+
+def test_options_off_stay_off(home, rcdir):
+ (rcdir / "10-a.sh").write_text(":\n")
+
+ result = _run(SHOW, home, before="shopt -u failglob dotglob")
+
+ assert result.stdout.splitlines() == ["fg=off", "dg=off"]
+
+
+# ===========================================================================
+# 受け入れ条件 7: 誤りの後も続く
+# ===========================================================================
+
+def test_broken_file_does_not_stop_later_files(home, rcdir):
+ (rcdir / "10-a.sh").write_text("echo read-a\n")
+ (rcdir / "15-bad.sh").write_text("if then fi (\n")
+ (rcdir / "20-b.sh").write_text("echo read-b\n")
+
+ result = _run("", home)
+
+ assert result.stdout.splitlines() == ["read-a", "read-b"]
+ assert "15-bad.sh" in result.stderr
+
+
+@pytest.mark.skipif(os.geteuid() == 0, reason="root は chmod 000 のファイルも読める")
+def test_unreadable_file_is_skipped_and_later_files_continue(home, rcdir):
+ (rcdir / "10-a.sh").write_text("echo read-a\n")
+ noperm = rcdir / "20-noperm.sh"
+ noperm.write_text("echo read-noperm\n")
+ noperm.chmod(0o000)
+ (rcdir / "30-b.sh").write_text("echo read-b\n")
+
+ result = _run("", home)
+
+ assert result.stdout.splitlines() == ["read-a", "read-b"]
+ assert result.returncode == 0
+
+
+@pytest.mark.parametrize("include_unreadable", [False, True])
+def test_unreadable_and_dangling_files_are_silently_skipped(home, rcdir, include_unreadable):
+ if include_unreadable and os.geteuid() == 0:
+ pytest.skip("root は chmod 000 のファイルも読める")
+
+ (rcdir / "10-a.sh").write_text("echo read-a\n")
+ (rcdir / "17-dangling.sh").symlink_to(rcdir / "missing")
+ (rcdir / "20-b.sh").write_text("echo read-b\n")
+ unreadable = rcdir / "15-unreadable.sh"
+ if include_unreadable:
+ unreadable.write_text("echo read-u\n")
+ unreadable.chmod(0o000)
+
+ try:
+ result = _run('echo "rc=$?"', home)
+
+ assert result.stdout.splitlines() == ["read-a", "read-b", "rc=0"]
+ assert result.stderr == ""
+ assert result.returncode == 0
+ finally:
+ if include_unreadable:
+ unreadable.chmod(0o644)
+
+
+# ===========================================================================
+# 受け入れ条件 8: 起動定義より勝つ
+# ===========================================================================
+
+def test_directory_alias_wins_over_launcher_definition(home, rcdir):
+ (rcdir / "ndf-relay.sh").write_text("alias claude='echo relayed'\n")
+
+ result = _run("alias claude", home, before=f'. "{ALIASES}"')
+
+ assert _alias(result, "claude") == "alias claude='echo relayed'"
+
+
+# ===========================================================================
+# 受け入れ条件 9: 変数を残さない
+# ===========================================================================
+
+@pytest.mark.parametrize("populated", [False, True])
+def test_loader_leaves_no_variables(home, populated):
+ if populated:
+ (home / ".shellrc.d").mkdir()
+ (home / ".shellrc.d" / "a.sh").write_text(":\n")
+
+ result = _run('echo "f=$f"; compgen -v __devbase_ || true', home, before="f=keep")
+
+ assert result.stdout.splitlines() == ["f=keep"]
+
+
+# ===========================================================================
+# 受け入れ条件 10: 置き場所は DEVBASE_SHELLRC_DIR、空なら ~/.shellrc.d
+# ===========================================================================
+
+def test_variable_points_at_directory(home, rcdir, tmp_path):
+ other = tmp_path / "other"
+ other.mkdir()
+ (other / "a.sh").write_text("echo from-variable\n")
+ (rcdir / "a.sh").write_text("echo from-home\n")
+
+ result = _run("", home, env={"DEVBASE_SHELLRC_DIR": str(other)})
+
+ assert result.stdout.splitlines() == ["from-variable"]
+
+
+def test_missing_variable_directory_does_not_fall_back_to_home(home, rcdir, tmp_path):
+ (rcdir / "a.sh").write_text("echo from-home\n")
+
+ result = _run('echo "rc=$?"', home,
+ env={"DEVBASE_SHELLRC_DIR": str(tmp_path / "missing")})
+
+ assert result.stdout == "rc=0\n"
+ assert result.stderr == ""
+
+
+@pytest.mark.parametrize("env", [{}, {"DEVBASE_SHELLRC_DIR": ""}])
+def test_empty_variable_falls_back_to_home(home, rcdir, env):
+ (rcdir / "a.sh").write_text("echo from-home\n")
+
+ result = _run("", home, env=env)
+
+ assert result.stdout.splitlines() == ["from-home"]
+
+
+def test_directory_and_file_names_with_spaces_are_read(home, tmp_path):
+ """置き場所・ファイル名に空白があっても単語分割されずに読む(現状固定)。"""
+ spaced = tmp_path / "a dir"
+ spaced.mkdir()
+ (spaced / "10 x.sh").write_text("echo read-space\n")
+ (spaced / "20-b.sh").write_text("echo read-b\n")
+
+ result = _run('echo "rc=$?"', home, env={"DEVBASE_SHELLRC_DIR": str(spaced)})
+
+ assert result.stdout.splitlines() == ["read-space", "read-b", "rc=0"]
+ assert result.stderr == ""
+
+
+def test_directory_and_file_names_with_glob_chars_are_read(home, tmp_path):
+ """置き場所・ファイル名にグロブ文字 (``[x]``) と空白があっても、単語分割も
+ 再展開もされずに名前の昇順で読む(現状固定)。
+
+ 実在するパスへ ``DEVBASE_SHELLRC_DIR`` を向け、両ファイルの作用が名前順で
+ 呼び出し元シェルに残り、終了状態 0・標準エラー空になることを確認する。
+ 実装テキストや表示メッセージは固定しない。
+ """
+ spaced = tmp_path / "rc dir [x]"
+ spaced.mkdir()
+ (spaced / "10 first [a].sh").write_text("ORDER=first\n")
+ (spaced / "20 second *.sh").write_text('ORDER="$ORDER second"\n')
+
+ result = _run('echo "rc=$?"; echo "ORDER=$ORDER"', home,
+ env={"DEVBASE_SHELLRC_DIR": str(spaced)})
+
+ assert result.stdout.splitlines() == ["rc=0", "ORDER=first second"]
+ assert result.stderr == ""
+
+
+def test_directory_that_is_a_symlink_is_followed(home, tmp_path):
+ """置き場所が別ディレクトリへの symlink でも中の ``*.sh`` を読む。
+
+ 実配置では entrypoint.sh が置き場所をアカウントグループのボリュームへの
+ symlink にする (``shellrc-dir.sh`` 冒頭のコメント)。``rcdir`` フィクスチャは
+ 実ディレクトリしか作らないため使わず、symlink 先を辿る経路を固定する。
+ """
+ volume = tmp_path / "volume"
+ volume.mkdir()
+ (volume / "a.sh").write_text("echo read-volume\n")
+ (home / ".shellrc.d").symlink_to(volume)
+
+ result = _run('echo "rc=$?"', home)
+
+ assert result.stdout.splitlines() == ["read-volume", "rc=0"]
+ assert result.stderr == ""
+
+
+# ===========================================================================
+# 非機能の性能: 外部コマンドもサブシェルも起動しない
+# ===========================================================================
+
+@pytest.mark.parametrize("populated", [False, True])
+def test_loader_runs_without_path(home, populated):
+ if populated:
+ (home / ".shellrc.d").mkdir()
+ (home / ".shellrc.d" / "a.sh").write_text("alias probe='echo p'\n")
+
+ result = _run('echo "rc=$?"', home, path="")
+
+ assert result.stdout == "rc=0\n"
+ assert result.stderr == ""
+
+
+def test_loader_has_no_command_substitution_or_pipe():
+ statements = "\n".join(
+ line for line in LOADER.read_text().splitlines()
+ if line.strip() and not line.lstrip().startswith("#")
+ )
+ for token in ("$(", "`", "|"):
+ assert token not in statements, f"{token!r} を使っている"
+
+
+# ===========================================================================
+# 配置 (受け入れ条件 8 / 11 / 14): Dockerfile
+# ===========================================================================
+
+def test_dockerfile_copies_loader_into_etc_devbase():
+ dockerfile = DOCKERFILE.read_text()
+ mkdir = "RUN sudo install -d -m 0755 /etc/devbase"
+ copy = "COPY --chmod=0644 shellrc-dir.sh /etc/devbase/shellrc-dir.sh"
+
+ assert copy in dockerfile
+ assert dockerfile.index(mkdir) < dockerfile.index(copy)
+
+
+def test_dockerfile_declares_shellrc_dir_env():
+ dockerfile = DOCKERFILE.read_text()
+
+ assert re.search(r"^ENV DEVBASE_SHELLRC_DIR=/home/\$\{USERNAME\}/\.shellrc\.d$",
+ dockerfile, re.MULTILINE)
+
+
+def test_bashrc_reads_loader_after_launcher_definitions():
+ dockerfile = DOCKERFILE.read_text()
+ aliases = "echo '. /etc/devbase/ai-cli-aliases.sh' >> ~/.bashrc"
+ loader = "echo '. /etc/devbase/shellrc-dir.sh' >> ~/.bashrc"
+
+ assert loader in dockerfile
+ assert dockerfile.index(aliases) < dockerfile.index(loader)
+
+
+def test_dockerfile_does_not_touch_zshrc_for_loader():
+ """zsh は対象外 (設計の決定 5)。読み込み器を ~/.zshrc へ書かない。"""
+ for line in DOCKERFILE.read_text().splitlines():
+ if "shellrc" in line:
+ assert ".zshrc" not in line, line